@arsedizioni/ars-utils 22.0.21 → 22.0.22

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,2091 +1,2008 @@
1
1
  import * as i0 from '@angular/core';
2
- import { Pipe, inject, makeEnvironmentProviders, Injectable, ElementRef, afterNextRender, Directive, input, DestroyRef, HostListener, output, forwardRef, EventEmitter, signal, computed, Service, PLATFORM_ID, RendererFactory2 } from '@angular/core';
3
- import { parseISO, parse, format, getYear, getMonth, getDate, getDay, getDaysInMonth, addYears, addMonths, addDays, formatISO, isDate, isValid, endOfDay } from 'date-fns';
4
- import { it } from 'date-fns/locale';
5
- import { TZDate } from '@date-fns/tz';
6
- import { DomSanitizer } from '@angular/platform-browser';
2
+ import { inject, Injectable, makeEnvironmentProviders, ElementRef, afterNextRender, Directive, input, DestroyRef, HostListener, output, forwardRef, effect, Pipe, EventEmitter, signal, computed, Service, PLATFORM_ID, RendererFactory2 } from '@angular/core';
7
3
  import { DateAdapter, MAT_DATE_LOCALE, MAT_DATE_FORMATS } from '@angular/material/core';
4
+ import { TZDate } from '@date-fns/tz';
5
+ import { getYear, getMonth, getDate, getDay, getDaysInMonth, parseISO, parse, format, addYears, addMonths, addDays, formatISO, isDate, isValid, endOfDay } from 'date-fns';
8
6
  import { takeUntilDestroyed } from '@angular/core/rxjs-interop';
9
- import { Subject, BehaviorSubject } from 'rxjs';
10
- import { debounceTime } from 'rxjs/operators';
7
+ import { Subject, filter as filter$1, map as map$1, BehaviorSubject } from 'rxjs';
8
+ import { debounceTime, filter, map } from 'rxjs/operators';
9
+ import { it } from 'date-fns/locale';
11
10
  import { NG_VALIDATORS } from '@angular/forms';
11
+ import { DomSanitizer } from '@angular/platform-browser';
12
12
  import { SelectionModel } from '@angular/cdk/collections';
13
13
  import { isPlatformBrowser } from '@angular/common';
14
14
 
15
15
  /**
16
- * Zero-dependency Markdown to HTML converter.
17
- *
18
- * Design goals:
19
- * - Single line-based pass over blocks (no repeated full-string regex passes).
20
- * - Inline formatting never touches code spans / code blocks (stash & restore).
21
- * - No HTML entity escaping by default in normal text: the source passes as-is.
22
- * Exception: the content of code spans / code blocks is ALWAYS escaped, so
23
- * things like List<string> render correctly (the browser displays the
24
- * original characters; entities never reach the user or the clipboard).
25
- * Opt-in full escaping via { escapeHtml: true } for untrusted input.
26
- * - URL sanitization on links/images (javascript:, vbscript:, data: are dropped).
27
- * - Bounded result cache for repeated renders (Angular change detection friendly).
28
- *
29
- * Supported syntax: headings, paragraphs, hard/soft breaks, hr, blockquotes
30
- * (nested), fenced code blocks, inline code, bold/italic/strikethrough, links
31
- * (with title), images, autolinked bare URLs, ordered/unordered lists (nested,
32
- * task lists, ordered start offset), GFM tables with alignment, raw HTML
33
- * blocks (e.g. <table>...</table>) passed through verbatim when escapeHtml
34
- * is false: their newlines stay plain newlines, never converted to <br>.
35
- *
36
- * Known simplifications (documented, by design):
37
- * - A blank line terminates a list.
38
- * - Setext headings (=== / ---) are not supported, use # syntax.
39
- * - Reference-style links [text][ref] are not supported.
16
+ * Creates an array of the given length, filling each slot with the result of `valueFunction`.
17
+ * @param length - Number of elements to create.
18
+ * @param valueFunction - Factory called with each index to produce the element value.
19
+ * @returns Typed array of `length` elements.
40
20
  */
41
- class MarkdownUtils {
42
- // #region Cache
43
- static { this.cache = new Map(); }
44
- static { this.CACHE_MAX = 200; }
45
- /** Clears the internal result cache. */
46
- static clearCache() {
47
- this.cache.clear();
21
+ function range(length, valueFunction) {
22
+ const valuesArray = Array(length);
23
+ for (let i = 0; i < length; i++) {
24
+ valuesArray[i] = valueFunction(i);
48
25
  }
49
- // #endregion
50
- // #region Public API
51
- /**
52
- * Convert markdown to HTML.
53
- * @param markdown : the markdown source
54
- * @param options : conversion options
55
- * @returns : the HTML string ('' when input is falsy)
56
- */
57
- static toHtml(markdown, options) {
58
- if (!markdown)
59
- return '';
60
- const opts = { escapeHtml: false, breaks: true, ...options };
61
- const key = `${opts.escapeHtml ? 1 : 0}${opts.breaks ? 1 : 0}|${markdown}`;
62
- const hit = this.cache.get(key);
63
- if (hit !== undefined)
64
- return hit;
65
- // Normalize line endings and fix glued sentence punctuation ("dettaglio!Ecco")
66
- const source = markdown
67
- .replace(/\r\n?/g, '\n')
68
- .replace(/([A-Za-zÀ-ÿ][!?])([A-Za-zÀ-ÿ])/g, '$1 $2');
69
- const html = this.parseBlocks(source.split('\n'), opts).trim();
70
- if (this.cache.size >= this.CACHE_MAX) {
71
- this.cache.delete(this.cache.keys().next().value); // FIFO eviction
26
+ return valuesArray;
27
+ }
28
+ // date-fns doesn't have a way to read/print month names or days of the week directly,
29
+ // so we get them by formatting a date with a format that produces the desired month/day.
30
+ const MONTH_FORMATS = {
31
+ long: 'LLLL',
32
+ short: 'LLL',
33
+ narrow: 'LLLLL',
34
+ };
35
+ const DAY_OF_WEEK_FORMATS = {
36
+ long: 'EEEE',
37
+ short: 'EEE',
38
+ narrow: 'EEEEE',
39
+ };
40
+ const MAT_DATE_FNS_FORMATS = {
41
+ parse: {
42
+ dateInput: 'P',
43
+ },
44
+ display: {
45
+ dateInput: 'P',
46
+ monthYearLabel: 'LLL uuuu',
47
+ dateA11yLabel: 'PP',
48
+ monthYearA11yLabel: 'LLLL uuuu',
49
+ },
50
+ };
51
+ /**
52
+ * date-fns adapter that integrates Angular Material's date picker with the date-fns library,
53
+ * applying `Europe/Rome` timezone for all parsed and created dates.
54
+ */
55
+ class DateFnsAdapter extends DateAdapter {
56
+ constructor() {
57
+ super();
58
+ const matDateLocale = inject(MAT_DATE_LOCALE, { optional: true });
59
+ // MAT_DATE_LOCALE defaults to LOCALE_ID (a string): only a date-fns
60
+ // Locale OBJECT is usable here, a string would break format()/parse().
61
+ if (matDateLocale && typeof matDateLocale === 'object') {
62
+ this.setLocale(matDateLocale);
72
63
  }
73
- this.cache.set(key, html);
74
- return html;
75
64
  }
76
- // #endregion
77
- // #region Block parser (single pass)
78
- /** Block-level HTML tags that start a raw HTML block (passthrough, no <p>/<br>). */
79
- static { this.htmlBlockTags = new Set([
80
- 'address', 'article', 'aside', 'blockquote', 'caption', 'colgroup', 'col',
81
- 'dd', 'details', 'dialog', 'div', 'dl', 'dt', 'fieldset', 'figcaption',
82
- 'figure', 'footer', 'form', 'h1', 'h2', 'h3', 'h4', 'h5', 'h6', 'header',
83
- 'hr', 'iframe', 'li', 'main', 'nav', 'ol', 'p', 'pre', 'section', 'summary',
84
- 'table', 'tbody', 'td', 'tfoot', 'thead', 'th', 'tr', 'ul',
85
- ]); }
86
- /** Void/self-contained tags: a single tag, no closing tag expected. */
87
- static { this.htmlVoidTags = new Set(['hr', 'col', 'img', 'input']); }
88
65
  /**
89
- * Precompiled: first block-level tag occurrence anywhere in a line.
90
- * Anchored on the '<' literal, so scanning is cheap; the lookahead
91
- * prevents partial matches ('<td' must not match inside '<tdx').
66
+ * Returns the year component of the given date.
67
+ * @param date - The source date.
92
68
  */
93
- static { this.htmlBlockScanRe = new RegExp(`<(\\/?)(${[...MarkdownUtils.htmlBlockTags].join('|')})(?=[\\s/>])`, 'gi'); }
94
- /** Result of scanning a line for the start of a raw HTML block. */
95
- static findHtmlBlockStart(line) {
96
- // HTML comment candidate (skipping occurrences inside inline code spans)
97
- let comment = line.indexOf('<!--');
98
- while (comment !== -1 && this.insideCodeSpan(line, comment)) {
99
- comment = line.indexOf('<!--', comment + 4);
100
- }
101
- // Block tag candidate (same code-span guard)
102
- const re = this.htmlBlockScanRe;
103
- re.lastIndex = 0;
104
- let m;
105
- let tagHit;
106
- while ((m = re.exec(line)) !== null) {
107
- if (!this.insideCodeSpan(line, m.index)) {
108
- tagHit = { index: m.index, tag: m[2].toLowerCase(), isClose: m[1] === '/' };
109
- break;
110
- }
111
- }
112
- if (comment !== -1 && (!tagHit || comment < tagHit.index)) {
113
- return { index: comment, tag: '', isClose: false, isComment: true };
114
- }
115
- return tagHit ? { ...tagHit, isComment: false } : undefined;
69
+ getYear(date) {
70
+ return getYear(date);
116
71
  }
117
- /** True when `index` falls inside an inline code span (odd backtick count before it). */
118
- static insideCodeSpan(line, index) {
119
- let count = 0;
120
- for (let i = 0; i < index; i++) {
121
- if (line.charCodeAt(i) === 96 /* ` */)
122
- count++;
123
- }
124
- return (count & 1) === 1;
72
+ /**
73
+ * Returns the zero-based month index of the given date (0 = January).
74
+ * @param date - The source date.
75
+ */
76
+ getMonth(date) {
77
+ return getMonth(date);
125
78
  }
126
79
  /**
127
- * indexOf-based depth scanner (no regex, no allocations): walks `text`
128
- * adjusting `depth` for <tag ...> / </tag> occurrences of the SAME tag.
129
- * Self-closing <tag ... /> forms do not alter depth.
130
- * @returns [newDepth, endIndex] where endIndex is the position right after
131
- * the '>' that balanced the element, or -1 when still open.
80
+ * Returns the day-of-month of the given date (1-based).
81
+ * @param date - The source date.
132
82
  */
133
- static scanHtmlDepth(text, tag, depth) {
134
- const lower = text.toLowerCase();
135
- const open = '<' + tag;
136
- const close = '</' + tag;
137
- let i = 0;
138
- while ((i = lower.indexOf('<', i)) !== -1) {
139
- if (lower.startsWith(close, i) && this.isTagBoundary(lower, i + close.length)) {
140
- depth--;
141
- const gt = lower.indexOf('>', i);
142
- const end = gt === -1 ? lower.length : gt + 1;
143
- if (depth <= 0)
144
- return [0, end];
145
- i = end;
146
- }
147
- else if (lower.startsWith(open, i) && this.isTagBoundary(lower, i + open.length)) {
148
- const gt = lower.indexOf('>', i);
149
- const selfClosing = gt !== -1 && lower.charCodeAt(gt - 1) === 47 /* / */;
150
- if (selfClosing) {
151
- if (depth === 0)
152
- return [0, gt + 1]; // standalone self-closed element
153
- }
154
- else {
155
- depth++;
156
- }
157
- i = gt === -1 ? lower.length : gt + 1;
158
- }
159
- else {
160
- i++;
161
- }
162
- }
163
- return [depth, -1];
83
+ getDate(date) {
84
+ return getDate(date);
164
85
  }
165
- /** A tag token must be followed by whitespace, '/', '>' or end of line. */
166
- static isTagBoundary(s, idx) {
167
- if (idx >= s.length)
168
- return true; // tag opening continues on the next line
169
- const c = s.charCodeAt(idx);
170
- return c === 62 /* > */ || c === 47 /* / */ || c === 32 /* space */ || c === 9 /* tab */;
86
+ /**
87
+ * Returns the day-of-week of the given date (0 = Sunday).
88
+ * @param date - The source date.
89
+ */
90
+ getDayOfWeek(date) {
91
+ return getDay(date);
171
92
  }
172
93
  /**
173
- * Re-injects the remainder of a partially consumed line so the main loop
174
- * processes it as markdown. Returns the index the loop should resume from.
94
+ * Returns an array of 12 month name strings formatted for the active locale.
95
+ * @param style - One of `'long'`, `'short'`, or `'narrow'`.
175
96
  */
176
- static pushBack(lines, i, remainder) {
177
- if (remainder.trim()) {
178
- lines[i] = remainder;
179
- return i - 1; // the loop's i++ re-processes the remainder
180
- }
181
- return i;
97
+ getMonthNames(style) {
98
+ const pattern = MONTH_FORMATS[style];
99
+ return range(12, i => this.format(new Date(2017, i, 1), pattern));
182
100
  }
183
101
  /**
184
- * Emits a raw HTML block starting at `hit.index` of lines[i], consuming
185
- * following lines until the element is balanced. Text after the block on
186
- * the closing line is pushed back for markdown processing.
187
- * @returns the index of the last consumed line
102
+ * Returns an array of 31 day-of-month label strings formatted using `Intl.DateTimeFormat`
103
+ * when available, falling back to plain numeric strings.
188
104
  */
189
- static emitHtmlBlock(lines, i, hit, out) {
190
- const line = lines[i];
191
- // HTML comment: raw until '-->'
192
- if (hit.isComment) {
193
- let end = line.indexOf('-->', hit.index);
194
- if (end !== -1) {
195
- out.push(line.slice(hit.index, end + 3));
196
- return this.pushBack(lines, i, line.slice(end + 3));
105
+ getDateNames() {
106
+ const dtf = typeof Intl !== 'undefined'
107
+ ? new Intl.DateTimeFormat(this.locale?.code, {
108
+ day: 'numeric',
109
+ timeZone: 'utc',
110
+ })
111
+ : null;
112
+ return range(31, i => {
113
+ if (dtf) {
114
+ // date-fns doesn't appear to support this functionality.
115
+ // Fall back to `Intl` on supported browsers.
116
+ const date = new Date();
117
+ date.setUTCFullYear(2017, 0, i + 1);
118
+ date.setUTCHours(0, 0, 0, 0);
119
+ return dtf.format(date).replace(/[\u200e\u200f]/g, '');
197
120
  }
198
- out.push(line.slice(hit.index));
199
- while (++i < lines.length) {
200
- end = lines[i].indexOf('-->');
201
- if (end !== -1) {
202
- out.push(lines[i].slice(0, end + 3));
203
- return this.pushBack(lines, i, lines[i].slice(end + 3));
204
- }
205
- out.push(lines[i]);
206
- }
207
- return lines.length - 1; // unterminated: consumed to EOF
208
- }
209
- // Stray closing tag or void tag: emit just the tag, resume after '>'
210
- if (hit.isClose || this.htmlVoidTags.has(hit.tag)) {
211
- const gt = line.indexOf('>', hit.index);
212
- const end = gt === -1 ? line.length : gt + 1;
213
- out.push(line.slice(hit.index, end));
214
- return this.pushBack(lines, i, line.slice(end));
121
+ return String(i + 1);
122
+ });
123
+ }
124
+ /**
125
+ * Returns an array of 7 day-of-week name strings formatted for the active locale.
126
+ * @param style - One of `'long'`, `'short'`, or `'narrow'`.
127
+ */
128
+ getDayOfWeekNames(style) {
129
+ const pattern = DAY_OF_WEEK_FORMATS[style];
130
+ return range(7, i => this.format(new Date(2017, 0, i + 1), pattern));
131
+ }
132
+ /**
133
+ * Returns the four-digit year string for the given date.
134
+ * @param date - The source date.
135
+ */
136
+ getYearName(date) {
137
+ return this.format(date, 'y');
138
+ }
139
+ /**
140
+ * Returns the first day of the week for the active locale (0 = Sunday, 1 = Monday, …).
141
+ */
142
+ getFirstDayOfWeek() {
143
+ return this.locale?.options?.weekStartsOn ?? 0;
144
+ }
145
+ /**
146
+ * Returns the number of days in the month of the given date.
147
+ * @param date - The source date.
148
+ */
149
+ getNumDaysInMonth(date) {
150
+ return getDaysInMonth(date);
151
+ }
152
+ /**
153
+ * Creates an independent copy of the given date.
154
+ * @param date - The date to clone.
155
+ */
156
+ clone(date) {
157
+ return new Date(date.getTime());
158
+ }
159
+ /**
160
+ * Creates a `Date` in the `Europe/Rome` timezone for the given year, month, and day.
161
+ * Throws an `Error` when any component is out of range.
162
+ * @param year - Full four-digit year.
163
+ * @param month - Zero-based month index (0 = January, 11 = December).
164
+ * @param date - Day-of-month (1-based).
165
+ */
166
+ createDate(year, month, date) {
167
+ if (month < 0 || month > 11) {
168
+ throw Error(`Invalid month index "${month}". Month index has to be between 0 and 11.`);
215
169
  }
216
- // Opening block tag: raw until the element is balanced (depth === 0)
217
- let [depth, split] = this.scanHtmlDepth(line.slice(hit.index), hit.tag, 0);
218
- if (split !== -1) {
219
- out.push(line.slice(hit.index, hit.index + split));
220
- return this.pushBack(lines, i, line.slice(hit.index + split));
170
+ if (date < 1) {
171
+ throw Error(`Invalid date "${date}". Date has to be greater than 0.`);
221
172
  }
222
- out.push(line.slice(hit.index));
223
- while (depth > 0 && ++i < lines.length) {
224
- [depth, split] = this.scanHtmlDepth(lines[i], hit.tag, depth);
225
- if (split !== -1) {
226
- out.push(lines[i].slice(0, split));
227
- return this.pushBack(lines, i, lines[i].slice(split));
228
- }
229
- out.push(lines[i]);
173
+ // Passing the year to the constructor causes year numbers <100 to be converted to 19xx.
174
+ // To work around this we use `setFullYear` and `setHours` instead.
175
+ const result = new Date();
176
+ result.setFullYear(year, month, date);
177
+ result.setHours(0, 0, 0, 0);
178
+ const result2 = new TZDate(result, 'Europe/Rome');
179
+ if (result2.getMonth() !== month) {
180
+ throw Error(`Invalid date "${date}" for month with index "${month}".`);
230
181
  }
231
- return Math.min(i, lines.length - 1);
182
+ return result2;
232
183
  }
233
- static parseBlocks(lines, opts) {
234
- const out = [];
235
- let paragraph = [];
236
- const flush = () => {
237
- if (paragraph.length > 0) {
238
- const sep = opts.breaks ? '<br>' : ' ';
239
- out.push(`<p>${paragraph.map(l => this.inline(l, opts)).join(sep)}</p>`);
240
- paragraph = [];
241
- }
242
- };
243
- for (let i = 0; i < lines.length; i++) {
244
- const line = lines[i];
245
- const trimmed = line.trim();
246
- // --- Fenced code block -------------------------------------------------
247
- const fence = trimmed.match(/^```([\w+-]+)?\s*$/);
248
- if (fence) {
249
- flush();
250
- const lang = fence[1] ? ` class="language-${fence[1]}"` : '';
251
- const code = [];
252
- i++;
253
- while (i < lines.length && !/^```\s*$/.test(lines[i].trim())) {
254
- code.push(lines[i]);
255
- i++;
256
- }
257
- out.push(`<pre><code${lang}>${this.escape(code.join('\n'))}</code></pre>`);
258
- continue;
259
- }
260
- // --- Blank line --------------------------------------------------------
261
- if (!trimmed) {
262
- flush();
263
- continue;
264
- }
265
- // --- Raw HTML block passthrough (only with escaping disabled) ------------
266
- // A block-level HTML element found ANYWHERE in the line starts a raw
267
- // block: text before it joins the current paragraph, the element is
268
- // emitted as-is (plain newlines, no <p>, no <br>) until balanced, and
269
- // any text after the closing tag resumes markdown processing.
270
- if (!opts.escapeHtml && line.includes('<')) {
271
- const hit = this.findHtmlBlockStart(line);
272
- if (hit) {
273
- const before = line.slice(0, hit.index).trim();
274
- if (before)
275
- paragraph.push(before);
276
- flush();
277
- i = this.emitHtmlBlock(lines, i, hit, out);
278
- continue;
279
- }
280
- }
281
- // --- ATX heading -------------------------------------------------------
282
- const heading = trimmed.match(/^(#{1,6})\s+(.+?)\s*#*\s*$/);
283
- if (heading) {
284
- flush();
285
- const level = heading[1].length;
286
- out.push(`<h${level}>${this.inline(heading[2], opts)}</h${level}>`);
287
- continue;
288
- }
289
- // --- Horizontal rule ---------------------------------------------------
290
- if (/^(?:-{3,}|\*{3,}|_{3,})$/.test(trimmed)) {
291
- flush();
292
- out.push('<hr>');
293
- continue;
184
+ /**
185
+ * Returns today's date in the local timezone.
186
+ */
187
+ today() {
188
+ return new Date();
189
+ }
190
+ /**
191
+ * Parses a value into a `Date`.
192
+ * - Strings are first attempted as ISO 8601, then matched against each format in `parseFormat`.
193
+ * - Numbers are treated as Unix timestamps (milliseconds).
194
+ * - Existing `Date` instances are cloned.
195
+ * @param value - The value to parse.
196
+ * @param parseFormat - A format string or an array of format strings (date-fns tokens).
197
+ * @returns A valid `Date` in `Europe/Rome`, an invalid sentinel, or `null` for unrecognised input.
198
+ */
199
+ parse(value, parseFormat) {
200
+ if (typeof value === 'string' && value.length > 0) {
201
+ const iso8601Date = parseISO(value);
202
+ if (this.isValid(iso8601Date)) {
203
+ return new TZDate(iso8601Date, 'Europe/Rome');
294
204
  }
295
- // --- Blockquote (consecutive lines, one level stripped, recursive) ------
296
- if (/^>\s?/.test(trimmed)) {
297
- flush();
298
- const quoted = [];
299
- while (i < lines.length && /^>\s?/.test(lines[i].trim())) {
300
- quoted.push(lines[i].trim().replace(/^>\s?/, ''));
301
- i++;
302
- }
303
- i--;
304
- out.push(`<blockquote>${this.parseBlocks(quoted, opts)}</blockquote>`);
305
- continue;
205
+ const formats = Array.isArray(parseFormat) ? parseFormat : [parseFormat];
206
+ if (!formats.length) {
207
+ throw Error('Formats array must not be empty.');
306
208
  }
307
- // --- Table (header row + separator row) ---------------------------------
308
- if (trimmed.includes('|') &&
309
- i + 1 < lines.length &&
310
- lines[i + 1].includes('-') &&
311
- /^\|?[\s:\-|]+\|?$/.test(lines[i + 1].trim())) {
312
- flush();
313
- const aligns = this.tableAligns(lines[i + 1].trim());
314
- const head = this.tableRow(trimmed, 'th', aligns, opts);
315
- const body = [];
316
- i += 2;
317
- while (i < lines.length && lines[i].trim().includes('|')) {
318
- body.push(this.tableRow(lines[i].trim(), 'td', aligns, opts));
319
- i++;
209
+ for (const currentFormat of formats) {
210
+ const fromFormat = parse(value, currentFormat, new Date(), { locale: this.locale });
211
+ if (this.isValid(fromFormat)) {
212
+ return new TZDate(fromFormat, 'Europe/Rome');
320
213
  }
321
- i--;
322
- out.push(`<table>\n<thead>\n${head}\n</thead>` +
323
- (body.length > 0 ? `\n<tbody>\n${body.join('\n')}\n</tbody>` : '') +
324
- `\n</table>`);
325
- continue;
326
- }
327
- // --- List ---------------------------------------------------------------
328
- if (MarkdownUtils.listItemRe.test(line)) {
329
- flush();
330
- const [html, last] = this.parseList(lines, i, opts);
331
- out.push(html);
332
- i = last;
333
- continue;
334
214
  }
335
- // --- Plain text: accumulate into current paragraph ----------------------
336
- paragraph.push(trimmed);
215
+ return this.invalid();
337
216
  }
338
- flush();
339
- return out.join('\n');
217
+ else if (typeof value === 'number') {
218
+ return new Date(value);
219
+ }
220
+ else if (value instanceof Date) {
221
+ return this.clone(value);
222
+ }
223
+ return null;
340
224
  }
341
- // #endregion
342
- // #region Lists
343
- /** Matches "- item", "* item", "+ item", "1. item", "1) item" with leading indent. */
344
- static { this.listItemRe = /^(\s*)([-*+]|\d+[.)])\s+(.*)$/; }
345
225
  /**
346
- * Gathers the contiguous list block starting at `start`, builds it (with
347
- * nesting) and returns [html, indexOfLastConsumedLine].
226
+ * Formats a `Date` using the given date-fns display format string.
227
+ * Throws an `Error` when `date` is not valid.
228
+ * @param date - The date to format.
229
+ * @param displayFormat - A date-fns format string (e.g. `'P'`, `'LLL uuuu'`).
348
230
  */
349
- static parseList(lines, start, opts) {
350
- const block = [];
351
- let i = start;
352
- while (i < lines.length) {
353
- const l = lines[i];
354
- if (!l.trim())
355
- break; // blank line ends the list
356
- if (this.listItemRe.test(l) || /^\s{2,}\S/.test(l)) {
357
- block.push(l);
358
- i++;
359
- }
360
- else
361
- break;
231
+ format(date, displayFormat) {
232
+ if (!this.isValid(date)) {
233
+ throw Error('DateFnsAdapter: Cannot format invalid date.');
362
234
  }
363
- return [this.buildList(block, opts), i - 1];
235
+ return format(date, displayFormat, { locale: this.locale });
364
236
  }
365
- static buildList(block, opts) {
366
- const first = block[0].match(this.listItemRe);
367
- const baseIndent = first[1].length;
368
- const ordered = /^\d/.test(first[2]);
369
- const startNum = ordered ? parseInt(first[2], 10) : 1;
370
- const items = [];
371
- for (const l of block) {
372
- const m = l.match(this.listItemRe);
373
- if (m && m[1].length <= baseIndent) {
374
- items.push({ text: m[3], childLines: [] });
375
- }
376
- else if (items.length > 0) {
377
- // Continuation or nested content: strip one nesting level of indent
378
- items[items.length - 1].childLines.push(l.length > baseIndent + 2 ? l.slice(baseIndent + 2) : l.trim());
379
- }
380
- }
381
- const lis = items.map(item => {
382
- let inner;
383
- // GFM task list: - [ ] / - [x]
384
- const task = item.text.match(/^\[([ xX])\]\s+(.*)$/);
385
- if (task) {
386
- const checked = task[1] !== ' ' ? ' checked' : '';
387
- inner = `<input type="checkbox" disabled${checked}> ${this.inline(task[2], opts)}`;
388
- }
389
- else {
390
- inner = this.inline(item.text, opts);
391
- }
392
- if (item.childLines.length > 0) {
393
- inner += '\n' + this.parseBlocks(item.childLines, opts);
394
- }
395
- return `<li>${inner}</li>`;
396
- });
397
- const tag = ordered ? 'ol' : 'ul';
398
- const startAttr = ordered && startNum > 1 ? ` start="${startNum}"` : '';
399
- return `<${tag}${startAttr}>\n${lis.join('\n')}\n</${tag}>`;
400
- }
401
- // #endregion
402
- // #region Tables
403
- static tableAligns(separator) {
404
- return separator
405
- .replace(/^\||\|$/g, '')
406
- .split('|')
407
- .map(cell => {
408
- const t = cell.trim();
409
- if (t.startsWith(':') && t.endsWith(':'))
410
- return 'center';
411
- if (t.endsWith(':'))
412
- return 'right';
413
- if (t.startsWith(':'))
414
- return 'left';
415
- return undefined;
416
- });
417
- }
418
- static tableRow(row, tag, aligns, opts) {
419
- const cells = row.replace(/^\||\|$/g, '').split('|').map(c => c.trim());
420
- const cellsHtml = cells
421
- .map((cell, j) => {
422
- const align = aligns[j] ? ` align="${aligns[j]}"` : '';
423
- return `<${tag}${align}>${this.inline(cell, opts)}</${tag}>`;
424
- })
425
- .join('');
426
- return `<tr>${cellsHtml}</tr>`;
237
+ /**
238
+ * Adds the given number of whole years to a date.
239
+ * @param date - The base date.
240
+ * @param years - Number of years to add (can be negative).
241
+ */
242
+ addCalendarYears(date, years) {
243
+ return addYears(date, years);
427
244
  }
428
- // #endregion
429
- // #region Inline formatting
430
245
  /**
431
- * Applies inline markdown to a single text segment.
432
- * Generated HTML (code, links, images, autolinks) is stashed behind \u0000
433
- * placeholders so later regex passes can never corrupt it.
246
+ * Adds the given number of whole months to a date.
247
+ * @param date - The base date.
248
+ * @param months - Number of months to add (can be negative).
434
249
  */
435
- static inline(text, opts) {
436
- const store = [];
437
- const stash = (html) => `\u0000${store.push(html) - 1}\u0000`;
438
- // 1. Inline code: stash first so later passes can't touch it.
439
- // Content is ALWAYS escaped so <, >, & inside code survive in the DOM
440
- // (the browser renders entities back to the original characters).
441
- let s = text.replace(/`([^`]+)`/g, (_m, code) => stash(`<code>${this.escape(code)}</code>`));
442
- // 2. Escape raw HTML as entities (opt-in only)
443
- if (opts.escapeHtml)
444
- s = this.escape(s);
445
- // 3. Images: ![alt](url "title")
446
- s = s.replace(/!\[([^\]]*)\]\(\s*([^)\s]+)(?:\s+(?:"|&quot;)(.+?)(?:"|&quot;))?\s*\)/g, (_m, alt, src, title) => {
447
- const url = this.safeUrl(src);
448
- if (!url)
449
- return alt;
450
- const t = title ? ` title="${title}"` : '';
451
- return stash(`<img src="${url}" alt="${alt}"${t} loading="lazy">`);
452
- });
453
- // 4. Links: [text](url "title")
454
- s = s.replace(/\[([^\]]+)\]\(\s*([^)\s]+)(?:\s+(?:"|&quot;)(.+?)(?:"|&quot;))?\s*\)/g, (_m, txt, href, title) => {
455
- const url = this.safeUrl(href);
456
- if (!url)
457
- return txt;
458
- const t = title ? ` title="${title}"` : '';
459
- return stash(`<a href="${url}"${t} target="_blank" rel="noopener noreferrer">${txt}</a>`);
460
- });
461
- // 5. Autolink remaining bare URLs (markdown links are already stashed)
462
- s = s.replace(/https?:\/\/[^\s<>"']+/g, url => stash(`<a href="${url}" target="_blank" rel="noopener noreferrer">${url}</a>`));
463
- // 6. Bold / italic / strikethrough.
464
- // Delimiters must hug the text; underscore emphasis only at word
465
- // boundaries so identifiers like snake_case are left untouched.
466
- s = s.replace(/\*\*\*(?=\S)([\s\S]*?\S)\*\*\*/g, '<strong><em>$1</em></strong>');
467
- s = s.replace(/(?<![\w*])___(?=\S)([\s\S]*?\S)___(?![\w*])/g, '<strong><em>$1</em></strong>');
468
- s = s.replace(/\*\*(?=\S)([\s\S]*?\S)\*\*/g, '<strong>$1</strong>');
469
- s = s.replace(/(?<![\w*])__(?=\S)([\s\S]*?\S)__(?![\w*])/g, '<strong>$1</strong>');
470
- s = s.replace(/\*(?=\S)([^*\n]*?\S)\*/g, '<em>$1</em>');
471
- s = s.replace(/(?<![\w_])_(?=\S)([^_\n]*?\S)_(?![\w_])/g, '<em>$1</em>');
472
- s = s.replace(/~~(?=\S)([\s\S]*?\S)~~/g, '<del>$1</del>');
473
- // 7. Restore stashed HTML
474
- return s.replace(/\u0000(\d+)\u0000/g, (_m, idx) => store[Number(idx)] ?? '');
250
+ addCalendarMonths(date, months) {
251
+ return addMonths(date, months);
475
252
  }
476
- // #endregion
477
- // #region Security helpers
478
- /** Escapes &, <, > and " for safe HTML interpolation. */
479
- static escape(s) {
480
- return s
481
- .replace(/&/g, '&amp;')
482
- .replace(/</g, '&lt;')
483
- .replace(/>/g, '&gt;')
484
- .replace(/"/g, '&quot;');
253
+ /**
254
+ * Adds the given number of whole days to a date.
255
+ * @param date - The base date.
256
+ * @param days - Number of days to add (can be negative).
257
+ */
258
+ addCalendarDays(date, days) {
259
+ return addDays(date, days);
485
260
  }
486
261
  /**
487
- * Returns a sanitized URL or undefined when the scheme is dangerous.
488
- * Blocks javascript:, vbscript: and data: (also when obfuscated with
489
- * whitespace/control characters, e.g. "java\tscript:").
262
+ * Serialises a date to an ISO 8601 date string (`yyyy-MM-dd`).
263
+ * @param date - The date to serialise.
490
264
  */
491
- static safeUrl(url) {
492
- const compact = url.replace(/[\s\u0000-\u001f]/g, '');
493
- if (/^(javascript|vbscript|data):/i.test(compact))
494
- return undefined;
495
- return url.replace(/"/g, '%22');
265
+ toIso8601(date) {
266
+ return formatISO(date, { representation: 'date' });
496
267
  }
497
- // #endregion
498
- // #region Clipboard
499
268
  /**
500
- * Copies markdown content to the clipboard in two flavors:
501
- * - text/html : the rendered HTML (rich paste into Word, Outlook, Gmail...)
502
- * - text/plain : the original markdown source (paste into editors, IDEs...)
503
- * @param markdown : the markdown source
504
- * @param options : conversion options for the HTML flavor
505
- * @returns : true on success
269
+ * Returns the given value when it is a valid `Date`, or `null` for an empty string.
270
+ * Deserialises valid ISO 8601 strings into `Date` instances.
271
+ * Delegates all other values to the base-class implementation.
272
+ * @param value - The raw value to deserialise.
506
273
  */
507
- static async copyToClipboard(markdown, options) {
508
- if (!markdown || typeof navigator === 'undefined' || !navigator.clipboard)
509
- return false;
510
- try {
511
- if (typeof ClipboardItem !== 'undefined') {
512
- const html = this.toHtml(markdown, options);
513
- await navigator.clipboard.write([
514
- new ClipboardItem({
515
- 'text/html': new Blob([html], { type: 'text/html' }),
516
- 'text/plain': new Blob([markdown], { type: 'text/plain' }),
517
- }),
518
- ]);
274
+ deserialize(value) {
275
+ if (typeof value === 'string') {
276
+ if (!value) {
277
+ return null;
519
278
  }
520
- else {
521
- // Older engines: plain text only
522
- await navigator.clipboard.writeText(markdown);
279
+ const date = parseISO(value);
280
+ if (this.isValid(date)) {
281
+ return date;
523
282
  }
524
- return true;
525
- }
526
- catch {
527
- // Clipboard API requires a secure context and user activation
528
- return false;
529
283
  }
284
+ return super.deserialize(value);
530
285
  }
531
286
  /**
532
- * Copies plain text (e.g. the content of a single code block) to the clipboard.
533
- * @param text : the text to copy
534
- * @returns : true on success
287
+ * Returns `true` when `obj` is an instance of `Date`.
288
+ * @param obj - The object to test.
535
289
  */
536
- static async copyText(text) {
537
- if (!text || typeof navigator === 'undefined' || !navigator.clipboard)
538
- return false;
539
- try {
540
- await navigator.clipboard.writeText(text);
541
- return true;
542
- }
543
- catch {
544
- return false;
545
- }
290
+ isDateInstance(obj) {
291
+ return isDate(obj);
546
292
  }
547
293
  /**
548
- * Extracts the visible plain text from a rendered element
549
- * (what the user sees, entities already decoded by the browser).
550
- * @param element : the element hosting the rendered markdown
551
- * @returns : the plain text
294
+ * Returns `true` when `date` represents a valid point in time.
295
+ * @param date - The date to validate.
552
296
  */
553
- static elementToText(element) {
554
- return element.innerText ?? element.textContent ?? '';
297
+ isValid(date) {
298
+ return isValid(date);
555
299
  }
556
- }
557
-
558
- var DateFormat;
559
- (function (DateFormat) {
560
- DateFormat[DateFormat["Short"] = 1] = "Short";
561
- DateFormat[DateFormat["Long"] = 2] = "Long";
562
- DateFormat[DateFormat["LongWithShortMonth"] = 3] = "LongWithShortMonth";
563
- DateFormat[DateFormat["LongWithWeekDay"] = 4] = "LongWithWeekDay";
564
- DateFormat[DateFormat["LongWithShortWeekDay"] = 5] = "LongWithShortWeekDay";
565
- DateFormat[DateFormat["MonthAndYear"] = 6] = "MonthAndYear";
566
- DateFormat[DateFormat["LongMonthAndYear"] = 7] = "LongMonthAndYear";
567
- DateFormat[DateFormat["WeekDay"] = 8] = "WeekDay";
568
- DateFormat[DateFormat["LongWeekDay"] = 9] = "LongWeekDay";
569
- DateFormat[DateFormat["DayAndMonth"] = 10] = "DayAndMonth";
570
- DateFormat[DateFormat["ShortUS"] = 11] = "ShortUS";
571
- DateFormat[DateFormat["ShortISO8601"] = 12] = "ShortISO8601"; // yyyy-mm-dd
572
- })(DateFormat || (DateFormat = {}));
573
- class SystemUtils {
574
- /** Shared collator for locale-aware, case-insensitive string comparison. */
575
- static { this.collator = new Intl.Collator('it', { sensitivity: 'base', numeric: true }); }
576
300
  /**
577
- * Array find by key
578
- * @param array : the array to scan
579
- * @param key : key name
580
- * @param value : the value to search for
581
- * @returns : the property value or null
301
+ * Returns a sentinel `Date` that represents an invalid date (`new Date(NaN)`).
582
302
  */
583
- static arrayFindByKey(array, key, value) {
584
- if (!array)
585
- return undefined;
586
- for (const item of array) {
587
- if (item[key] === value) {
588
- return item;
589
- }
590
- }
591
- return undefined;
303
+ invalid() {
304
+ return new Date(NaN);
592
305
  }
593
- /**
594
- * Array find index by key
595
- * @param array : the array to scan
596
- * @param key : the key name
597
- * @param value : the value to search for
598
- * @returns : the array index or -1 if not found
599
- */
600
- static arrayFindIndexByKey(array, key, value) {
601
- if (!array)
602
- return -1;
603
- for (let i = 0; i < array.length; i++) {
604
- if (array[i][key] === value) {
605
- return i;
306
+ static { this.ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "22.0.1", ngImport: i0, type: DateFnsAdapter, deps: [], target: i0.ɵɵFactoryTarget.Injectable }); }
307
+ static { this.ɵprov = i0.ɵɵngDeclareInjectable({ minVersion: "12.0.0", version: "22.0.1", ngImport: i0, type: DateFnsAdapter }); }
308
+ }
309
+ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "22.0.1", ngImport: i0, type: DateFnsAdapter, decorators: [{
310
+ type: Injectable
311
+ }], ctorParameters: () => [] });
312
+ /**
313
+ * Standalone providers for the scm-utils date-fns adapter.
314
+ *
315
+ * Configures Angular Material to use {@link DateFnsAdapter} (Europe/Rome timezone)
316
+ * and the matching {@link MAT_DATE_FNS_FORMATS}.
317
+ *
318
+ * @example
319
+ * bootstrapApplication(AppComponent, {
320
+ * providers: [provideSCMDateFns()]
321
+ * });
322
+ */
323
+ function provideSCMDateFns() {
324
+ return makeEnvironmentProviders([
325
+ {
326
+ provide: DateAdapter,
327
+ useClass: DateFnsAdapter,
328
+ deps: [MAT_DATE_LOCALE],
329
+ },
330
+ { provide: MAT_DATE_FORMATS, useValue: MAT_DATE_FNS_FORMATS }
331
+ ]);
332
+ }
333
+
334
+ /**
335
+ * Directive that moves browser focus to the host element after the first render cycle.
336
+ * Apply `autoFocus` to any focusable element to set focus automatically on initialisation.
337
+ */
338
+ class AutoFocusDirective {
339
+ constructor() {
340
+ this.elementRef = inject(ElementRef);
341
+ afterNextRender(() => {
342
+ this.elementRef.nativeElement?.focus();
343
+ });
344
+ }
345
+ static { this.ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "22.0.1", ngImport: i0, type: AutoFocusDirective, deps: [], target: i0.ɵɵFactoryTarget.Directive }); }
346
+ static { this.ɵdir = i0.ɵɵngDeclareDirective({ minVersion: "14.0.0", version: "22.0.1", type: AutoFocusDirective, isStandalone: true, selector: "[autoFocus]", ngImport: i0 }); }
347
+ }
348
+ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "22.0.1", ngImport: i0, type: AutoFocusDirective, decorators: [{
349
+ type: Directive,
350
+ args: [{
351
+ selector: '[autoFocus]',
352
+ standalone: true
353
+ }]
354
+ }], ctorParameters: () => [] });
355
+
356
+ class FileInfo {
357
+ isValid() {
358
+ return this.valid;
359
+ }
360
+ }
361
+ class ValueModel {
362
+ }
363
+ class IDModel {
364
+ }
365
+ class GroupModel {
366
+ }
367
+ class DeleteModel extends GroupModel {
368
+ }
369
+ class RelationModel {
370
+ }
371
+ class UpdateRelationsModel {
372
+ }
373
+ class QueryModel {
374
+ }
375
+ class ImportModel {
376
+ }
377
+ class DateInterval {
378
+ get fromAsDate() {
379
+ if (this.from) {
380
+ if (!(this.from instanceof Date)) {
381
+ this.from = new Date(this.from);
382
+ }
383
+ if (this.from) {
384
+ return new Date(this.from.getFullYear(), this.from.getMonth(), this.from.getDate(), 2, 0, 0);
606
385
  }
607
386
  }
608
- return -1;
387
+ return undefined;
609
388
  }
610
- /**
611
- * Get a value from and array made of name|value items
612
- * @param array : the array to scan
613
- * @param value : the value to search for
614
- * @returns : the property value or null if not found
615
- */
616
- static arrayGetValue(array, value) {
617
- if (!array)
618
- return undefined;
619
- for (const item of array) {
620
- const i = item;
621
- if (i['value'] === value) {
622
- return i['name'] ?? i['id'];
389
+ get toAsDate() {
390
+ if (this.to) {
391
+ if (!(this.to instanceof Date)) {
392
+ this.to = new Date(this.to);
393
+ }
394
+ if (this.to) {
395
+ return new Date(this.to.getFullYear(), this.to.getMonth(), this.to.getDate(), 2, 0, 0);
623
396
  }
624
397
  }
625
398
  return undefined;
626
399
  }
627
- /**
628
- * Convert items to nodes into a tree structure
629
- * @param items : list of nodes
630
- * @param parent : parent node
631
- * @returns : an array of INode objects
632
- */
633
- static arrayToNodes(items, parent) {
634
- const nodes = [];
635
- items.forEach(n => {
636
- const node = {
637
- id: n.id.toString(),
638
- name: n.name,
639
- count: n.count ? n.count : 0,
640
- children: undefined,
641
- parent,
642
- bag: n,
643
- };
644
- nodes.push(node);
645
- node.children =
646
- n.children && n.children.length > 0
647
- ? this.arrayToNodes(n.children, node)
648
- : [];
649
- });
650
- return nodes;
651
- }
652
- /**
653
- * Comparator factory for sorting arrays of objects by a given property key.
654
- * @param key - Name of the property to sort by.
655
- * @param order - Sort direction: `'asc'` (default) or `'desc'`.
656
- * @returns A comparator function that returns a negative, zero, or positive number.
657
- */
658
- static arraySortCompare(key, order = 'asc') {
659
- const dir = order === 'desc' ? -1 : 1;
660
- return (a, b) => {
661
- const varA = a[key];
662
- const varB = b[key];
663
- if (varA === varB)
664
- return 0;
665
- if (varA === undefined || varA === null)
666
- return -1 * dir;
667
- if (varB === undefined || varB === null)
668
- return 1 * dir;
669
- if (typeof varA === 'string' && typeof varB === 'string') {
670
- return SystemUtils.collator.compare(varA, varB) * dir;
671
- }
672
- return (varA > varB ? 1 : varA < varB ? -1 : 0) * dir;
673
- };
400
+ constructor(from, to) {
401
+ this.from = from;
402
+ this.to = to;
674
403
  }
675
- /**
676
- * Format weight
677
- * @param gr : grams
678
- * @returns : the formatted string
679
- */
680
- static formatWeight(gr) {
681
- if (gr > 1000000)
682
- return `${(gr / 1000000).toFixed(2)} t`;
683
- else if (gr > 100000)
684
- return `${(gr / 100000).toFixed(2)} q`;
685
- else if (gr > 1000)
686
- return `${(gr / 1000).toFixed(2)} kg`;
687
- else
688
- return `${gr} gr`;
404
+ clear() {
405
+ this.from = undefined;
406
+ this.to = undefined;
689
407
  }
690
- /**
691
- * Format file size
692
- * @param bytes : number of bytes
693
- * @returns : the formatted string
694
- */
695
- static formatFileSize(bytes) {
696
- if (bytes > 1024000)
697
- return `${(bytes / 1024000).toFixed(1)} MB`;
698
- if (bytes > 1024)
699
- return `${(bytes / 1024).toFixed(1)} Kb`;
700
- return `${bytes} byte`;
408
+ }
409
+
410
+ /**
411
+ * Zero-dependency Markdown to HTML converter.
412
+ *
413
+ * Design goals:
414
+ * - Single line-based pass over blocks (no repeated full-string regex passes).
415
+ * - Inline formatting never touches code spans / code blocks (stash & restore).
416
+ * - No HTML entity escaping by default in normal text: the source passes as-is.
417
+ * Exception: the content of code spans / code blocks is ALWAYS escaped, so
418
+ * things like List<string> render correctly (the browser displays the
419
+ * original characters; entities never reach the user or the clipboard).
420
+ * Opt-in full escaping via { escapeHtml: true } for untrusted input.
421
+ * - URL sanitization on links/images (javascript:, vbscript:, data: are dropped).
422
+ * - Bounded result cache for repeated renders (Angular change detection friendly).
423
+ *
424
+ * Supported syntax: headings, paragraphs, hard/soft breaks, hr, blockquotes
425
+ * (nested), fenced code blocks, inline code, bold/italic/strikethrough, links
426
+ * (with title), images, autolinked bare URLs, ordered/unordered lists (nested,
427
+ * task lists, ordered start offset), GFM tables with alignment, raw HTML
428
+ * blocks (e.g. <table>...</table>) passed through verbatim when escapeHtml
429
+ * is false: their newlines stay plain newlines, never converted to <br>.
430
+ *
431
+ * Known simplifications (documented, by design):
432
+ * - A blank line terminates a list.
433
+ * - Setext headings (=== / ---) are not supported, use # syntax.
434
+ * - Reference-style links [text][ref] are not supported.
435
+ */
436
+ class MarkdownUtils {
437
+ // #region Cache
438
+ static { this.cache = new Map(); }
439
+ static { this.CACHE_MAX = 200; }
440
+ /** Clears the internal result cache. */
441
+ static clearCache() {
442
+ this.cache.clear();
701
443
  }
444
+ // #endregion
445
+ // #region Public API
702
446
  /**
703
- * Compare two strings (case-insensitive, locale-aware, accent-insensitive).
704
- * @param a : string a
705
- * @param b : string b
706
- * @returns : 0 if equals, 1 if bigger, -1 if lower
447
+ * Convert markdown to HTML.
448
+ * @param markdown : the markdown source
449
+ * @param options : conversion options
450
+ * @returns : the HTML string ('' when input is falsy)
707
451
  */
708
- static compareString(a, b) {
709
- return this.collator.compare(a ?? '', b ?? '');
452
+ static toHtml(markdown, options) {
453
+ if (!markdown)
454
+ return '';
455
+ const opts = { escapeHtml: false, breaks: true, ...options };
456
+ const key = `${opts.escapeHtml ? 1 : 0}${opts.breaks ? 1 : 0}|${markdown}`;
457
+ const hit = this.cache.get(key);
458
+ if (hit !== undefined)
459
+ return hit;
460
+ // Normalize line endings and fix glued sentence punctuation ("dettaglio!Ecco").
461
+ // Only when the next char is uppercase (a new sentence), so URLs with query
462
+ // strings ("/page?id=1") and code identifiers are left untouched.
463
+ const source = markdown
464
+ .replace(/\r\n?/g, '\n')
465
+ .replace(/([a-zà-ÿ][!?])([A-ZÀ-Þ])/g, '$1 $2');
466
+ const html = this.parseBlocks(source.split('\n'), opts).trim();
467
+ if (this.cache.size >= this.CACHE_MAX) {
468
+ this.cache.delete(this.cache.keys().next().value); // FIFO eviction
469
+ }
470
+ this.cache.set(key, html);
471
+ return html;
710
472
  }
473
+ // #endregion
474
+ // #region Block parser (single pass)
475
+ /** Block-level HTML tags that start a raw HTML block (passthrough, no <p>/<br>). */
476
+ static { this.htmlBlockTags = new Set([
477
+ 'address', 'article', 'aside', 'blockquote', 'caption', 'colgroup', 'col',
478
+ 'dd', 'details', 'dialog', 'div', 'dl', 'dt', 'fieldset', 'figcaption',
479
+ 'figure', 'footer', 'form', 'h1', 'h2', 'h3', 'h4', 'h5', 'h6', 'header',
480
+ 'hr', 'iframe', 'li', 'main', 'nav', 'ol', 'p', 'pre', 'section', 'summary',
481
+ 'table', 'tbody', 'td', 'tfoot', 'thead', 'th', 'tr', 'ul',
482
+ ]); }
483
+ /** Void/self-contained tags: a single tag, no closing tag expected. */
484
+ static { this.htmlVoidTags = new Set(['hr', 'col', 'img', 'input']); }
711
485
  /**
712
- * Capitalize a string
713
- * @param s : the string to capitalize
714
- * @returns : the capitalized string
486
+ * Precompiled: first block-level tag occurrence anywhere in a line.
487
+ * Anchored on the '<' literal, so scanning is cheap; the lookahead
488
+ * prevents partial matches ('<td' must not match inside '<tdx').
715
489
  */
716
- static capitalize(s) {
717
- if (!s)
718
- return undefined;
719
- let b = "";
720
- let cap = true;
721
- for (const char of s) {
722
- if (char === " ") {
723
- b += char;
724
- cap = true;
725
- }
726
- else if (cap) {
727
- b += char.toUpperCase();
728
- cap = false;
729
- }
730
- else {
731
- b += char.toLowerCase();
490
+ static { this.htmlBlockScanRe = new RegExp(`<(\\/?)(${[...MarkdownUtils.htmlBlockTags].join('|')})(?=[\\s/>])`, 'gi'); }
491
+ /** Result of scanning a line for the start of a raw HTML block. */
492
+ static findHtmlBlockStart(line) {
493
+ // HTML comment candidate (skipping occurrences inside inline code spans)
494
+ let comment = line.indexOf('<!--');
495
+ while (comment !== -1 && this.insideCodeSpan(line, comment)) {
496
+ comment = line.indexOf('<!--', comment + 4);
497
+ }
498
+ // Block tag candidate (same code-span guard)
499
+ const re = this.htmlBlockScanRe;
500
+ re.lastIndex = 0;
501
+ let m;
502
+ let tagHit;
503
+ while ((m = re.exec(line)) !== null) {
504
+ if (!this.insideCodeSpan(line, m.index)) {
505
+ tagHit = { index: m.index, tag: m[2].toLowerCase(), isClose: m[1] === '/' };
506
+ break;
732
507
  }
733
508
  }
734
- return b;
509
+ if (comment !== -1 && (!tagHit || comment < tagHit.index)) {
510
+ return { index: comment, tag: '', isClose: false, isComment: true };
511
+ }
512
+ return tagHit ? { ...tagHit, isComment: false } : undefined;
735
513
  }
736
- /**
737
- * Truncate a string at the last word boundary before `max`.
738
- * @param s : the string to truncate
739
- * @param max : the max number of chars
740
- * @returns : the truncated string
741
- */
742
- static truncate(s, max = 500) {
743
- if (!s)
744
- return undefined;
745
- if (s.length < max)
746
- return s;
747
- const i = s.lastIndexOf(' ', max - 1);
748
- return i > 0 ? s.slice(0, i) : s;
514
+ /** True when `index` falls inside an inline code span (odd backtick count before it). */
515
+ static insideCodeSpan(line, index) {
516
+ let count = 0;
517
+ for (let i = 0; i < index; i++) {
518
+ if (line.charCodeAt(i) === 96 /* ` */)
519
+ count++;
520
+ }
521
+ return (count & 1) === 1;
749
522
  }
750
523
  /**
751
- * Join a list of strings
752
- * @param items : the list of strings
753
- * @param sep : the separator string
754
- * @param max : the maximum resulting string
755
- * @returns : the joined string
524
+ * indexOf-based depth scanner (no regex, no allocations): walks `text`
525
+ * adjusting `depth` for <tag ...> / </tag> occurrences of the SAME tag.
526
+ * Self-closing <tag ... /> forms do not alter depth.
527
+ * @returns [newDepth, endIndex] where endIndex is the position right after
528
+ * the '>' that balanced the element, or -1 when still open.
756
529
  */
757
- static join(items, sep = " ", max = 350) {
758
- if (!items || items.length === 0)
759
- return undefined;
760
- if (items.length > 1) {
761
- let l = 0;
762
- let s = "";
763
- while (s.length < max && items.length > l) {
764
- if (l > 0) {
765
- s += sep;
530
+ static scanHtmlDepth(text, tag, depth) {
531
+ const lower = text.toLowerCase();
532
+ const open = '<' + tag;
533
+ const close = '</' + tag;
534
+ let i = 0;
535
+ while ((i = lower.indexOf('<', i)) !== -1) {
536
+ if (lower.startsWith(close, i) && this.isTagBoundary(lower, i + close.length)) {
537
+ depth--;
538
+ const gt = lower.indexOf('>', i);
539
+ const end = gt === -1 ? lower.length : gt + 1;
540
+ if (depth <= 0)
541
+ return [0, end];
542
+ i = end;
543
+ }
544
+ else if (lower.startsWith(open, i) && this.isTagBoundary(lower, i + open.length)) {
545
+ const gt = lower.indexOf('>', i);
546
+ const selfClosing = gt !== -1 && lower.charCodeAt(gt - 1) === 47 /* / */;
547
+ if (selfClosing) {
548
+ if (depth === 0)
549
+ return [0, gt + 1]; // standalone self-closed element
766
550
  }
767
- s += items[l++];
551
+ else {
552
+ depth++;
553
+ }
554
+ i = gt === -1 ? lower.length : gt + 1;
768
555
  }
769
- if (items.length > l) {
770
- s += "...";
556
+ else {
557
+ i++;
771
558
  }
772
- return s;
773
559
  }
774
- return items[0];
775
- }
776
- /**
777
- * Normalize a string by converting it to lowercase and removing extra spaces, with special handling for acronyms and camelCase.
778
- * @param s : the string to normalize
779
- * @returns : The normalized string, or `undefined` if the input is falsy.
780
- */
781
- static normalizeDisplay(s) {
782
- if (!s)
783
- return s;
784
- return s
785
- .split(' ')
786
- .map((word, wordIndex) => {
787
- if (!word)
788
- return word;
789
- // If the word is all uppercase and contains at least one letter, we assume it's an acronym and leave it as is
790
- if (word === word.toUpperCase() && /[A-Z]/.test(word)) {
791
- return word;
792
- }
793
- // Otherwise, convert to lowercase letter by letter
794
- const chars = word.split('');
795
- return chars
796
- .map((char, charIndex) => {
797
- if (charIndex === 0 && wordIndex === 0) {
798
- // First letter of the first word:
799
- // remains uppercase only if the first 2 letters are both uppercase
800
- const secondChar = chars[1] ?? '';
801
- const keepUppercase = char === char.toUpperCase() &&
802
- secondChar === secondChar.toUpperCase() &&
803
- /[A-Z]/.test(char) &&
804
- /[A-Z]/.test(secondChar);
805
- return keepUppercase ? char : char.toLowerCase();
806
- }
807
- return char.toLowerCase();
808
- })
809
- .join('');
810
- })
811
- .join(' ');
560
+ return [depth, -1];
812
561
  }
813
- /**
814
- * Wraps bare URLs in the given string with `<a>` anchor tags.
815
- * @param s - The plain-text or HTML string to process.
816
- * @returns The string with URLs replaced by clickable links, or `''` when `s` is falsy.
817
- */
818
- static replaceAsHtml(s) {
819
- if (!s)
820
- return '';
821
- return s.replace(/https?:\/\/[^\s<]+/gi, v => `<a href="${v}" target="_blank" rel="noopener">${v}</a>`);
562
+ /** A tag token must be followed by whitespace, '/', '>' or end of line. */
563
+ static isTagBoundary(s, idx) {
564
+ if (idx >= s.length)
565
+ return true; // tag opening continues on the next line
566
+ const c = s.charCodeAt(idx);
567
+ return c === 62 /* > */ || c === 47 /* / */ || c === 32 /* space */ || c === 9 /* tab */;
822
568
  }
823
569
  /**
824
- * Convert markdown to html
825
- * @param markdown : the markdown data
826
- * @param escapeHtml : true to escape HTML. Default is false
827
- * @returns the html
570
+ * Re-injects the remainder of a partially consumed line so the main loop
571
+ * processes it as markdown. Returns the index the loop should resume from.
828
572
  */
829
- static markdownToHtml(markdown, escapeHtml = false) {
830
- return MarkdownUtils.toHtml(markdown, { escapeHtml: escapeHtml });
573
+ static pushBack(lines, i, remainder) {
574
+ if (remainder.trim()) {
575
+ lines[i] = remainder;
576
+ return i - 1; // the loop's i++ re-processes the remainder
577
+ }
578
+ return i;
831
579
  }
832
580
  /**
833
- * Compare two names
834
- * @param a : name a
835
- * @param b : name b
836
- * @returns : true if a equals b
581
+ * Emits a raw HTML block starting at `hit.index` of lines[i], consuming
582
+ * following lines until the element is balanced. Text after the block on
583
+ * the closing line is pushed back for markdown processing.
584
+ * @returns the index of the last consumed line
837
585
  */
838
- static compareNames(a, b) {
839
- if (a)
840
- a = a.trim();
841
- if (b)
842
- b = b.trim();
843
- if (a && b && a.length !== b.length)
844
- return false;
845
- if (this.compareString(a, b) === 0)
846
- return true;
847
- const p1 = (a ?? '').split(' ');
848
- const p2 = (b ?? '').split(' ');
849
- if (p1.length !== p2.length)
850
- return false;
851
- let matches = p1.length;
852
- for (const s1 of p1) {
853
- for (const s2 of p2) {
854
- if (this.compareString(s1, s2) === 0) {
855
- matches--;
856
- break;
586
+ static emitHtmlBlock(lines, i, hit, out) {
587
+ const line = lines[i];
588
+ // HTML comment: raw until '-->'
589
+ if (hit.isComment) {
590
+ let end = line.indexOf('-->', hit.index);
591
+ if (end !== -1) {
592
+ out.push(line.slice(hit.index, end + 3));
593
+ return this.pushBack(lines, i, line.slice(end + 3));
594
+ }
595
+ out.push(line.slice(hit.index));
596
+ while (++i < lines.length) {
597
+ end = lines[i].indexOf('-->');
598
+ if (end !== -1) {
599
+ out.push(lines[i].slice(0, end + 3));
600
+ return this.pushBack(lines, i, lines[i].slice(end + 3));
857
601
  }
602
+ out.push(lines[i]);
858
603
  }
604
+ return lines.length - 1; // unterminated: consumed to EOF
859
605
  }
860
- return matches === 0;
861
- }
862
- /**
863
- * Cipher a text
864
- * @param text : the text
865
- * @param key : the key
866
- * @param reverse : true to decode, false to encode
867
- * @returns :the ciphered text
868
- */
869
- static cipher(text, key, reverse = false) {
870
- if (!text || !key)
871
- return undefined;
872
- // Surrogate pair limit
873
- const bound = 0x10000;
874
- const keyLen = key.length;
875
- let result = '';
876
- for (let i = 0; i < text.length; i++) {
877
- let rotation = key.charCodeAt(i % keyLen);
878
- if (reverse)
879
- rotation = -rotation;
880
- result += String.fromCharCode((text.charCodeAt(i) + rotation + bound) % bound);
606
+ // Stray closing tag or void tag: emit just the tag, resume after '>'
607
+ if (hit.isClose || this.htmlVoidTags.has(hit.tag)) {
608
+ const gt = line.indexOf('>', hit.index);
609
+ const end = gt === -1 ? line.length : gt + 1;
610
+ out.push(line.slice(hit.index, end));
611
+ return this.pushBack(lines, i, line.slice(end));
881
612
  }
882
- return result;
613
+ // Opening block tag: raw until the element is balanced (depth === 0)
614
+ let [depth, split] = this.scanHtmlDepth(line.slice(hit.index), hit.tag, 0);
615
+ if (split !== -1) {
616
+ out.push(line.slice(hit.index, hit.index + split));
617
+ return this.pushBack(lines, i, line.slice(hit.index + split));
618
+ }
619
+ out.push(line.slice(hit.index));
620
+ while (depth > 0 && ++i < lines.length) {
621
+ [depth, split] = this.scanHtmlDepth(lines[i], hit.tag, depth);
622
+ if (split !== -1) {
623
+ out.push(lines[i].slice(0, split));
624
+ return this.pushBack(lines, i, lines[i].slice(split));
625
+ }
626
+ out.push(lines[i]);
627
+ }
628
+ return Math.min(i, lines.length - 1);
883
629
  }
884
- /**
885
- * Clone an object (deep copy).
886
- * Uses native structuredClone: handles Date, Map, Set, typed arrays and circular references.
887
- * @param obj : the object to clone
888
- * @returns : a new object
889
- */
890
- static clone(obj) {
891
- if (!obj)
892
- return {};
893
- return structuredClone(obj);
894
- }
895
- /**
896
- * Creates a deep clone of an object.
897
- * @param obj - The source object to clone.
898
- * @param dest - Optional pre-allocated destination object to merge the clone into.
899
- * @returns A deep copy of `obj`.
900
- */
901
- static deepClone(obj, dest) {
902
- const cloned = structuredClone(obj);
903
- if (dest) {
904
- Object.assign(dest, cloned);
905
- return dest;
906
- }
907
- return cloned;
908
- }
909
- /**
910
- * Returns `true` when `value` is a syntactically valid UUID string.
911
- * @param value - The string to validate.
912
- */
913
- static parseUUID(value) {
914
- const regex = /^({|()?[0-9a-fA-F]{8}-([0-9a-fA-F]{4}-){3}[0-9a-fA-F]{12}(}|))?$/;
915
- return !!value && value.length > 0 && regex.test(value);
916
- }
917
- /**
918
- * Returns `true` when `value` is a valid, non-empty (non-zero) UUID.
919
- * @param value - The string to validate.
920
- */
921
- static parseUUIDNotEmpty(value) {
922
- const regex = /^({|()?0{8}-(0{4}-){3}0{12}(}|))?$/;
923
- return this.parseUUID(value) && !regex.test(value ?? '');
924
- }
925
- /**
926
- * Return an empty UUID
927
- * @returns : the empty UUID
928
- */
929
- static emptyUUID() { return "00000000-0000-0000-0000-000000000000"; }
930
- ;
931
- /**
932
- * Create a new UUID
933
- * @returns : the string UUID
934
- */
935
- static generateUUID() {
936
- if (typeof crypto !== 'undefined' && typeof crypto.randomUUID === 'function') {
937
- return crypto.randomUUID();
630
+ static parseBlocks(lines, opts) {
631
+ const out = [];
632
+ let paragraph = [];
633
+ const flush = () => {
634
+ if (paragraph.length > 0) {
635
+ const sep = opts.breaks ? '<br>' : ' ';
636
+ out.push(`<p>${paragraph.map(l => this.inline(l, opts)).join(sep)}</p>`);
637
+ paragraph = [];
638
+ }
639
+ };
640
+ for (let i = 0; i < lines.length; i++) {
641
+ const line = lines[i];
642
+ const trimmed = line.trim();
643
+ // --- Fenced code block -------------------------------------------------
644
+ const fence = trimmed.match(/^```([\w+-]+)?\s*$/);
645
+ if (fence) {
646
+ flush();
647
+ const lang = fence[1] ? ` class="language-${fence[1]}"` : '';
648
+ const code = [];
649
+ i++;
650
+ while (i < lines.length && !/^```\s*$/.test(lines[i].trim())) {
651
+ code.push(lines[i]);
652
+ i++;
653
+ }
654
+ out.push(`<pre><code${lang}>${this.escape(code.join('\n'))}</code></pre>`);
655
+ continue;
656
+ }
657
+ // --- Blank line --------------------------------------------------------
658
+ if (!trimmed) {
659
+ flush();
660
+ continue;
661
+ }
662
+ // --- Raw HTML block passthrough (only with escaping disabled) ------------
663
+ // A block-level HTML element found ANYWHERE in the line starts a raw
664
+ // block: text before it joins the current paragraph, the element is
665
+ // emitted as-is (plain newlines, no <p>, no <br>) until balanced, and
666
+ // any text after the closing tag resumes markdown processing.
667
+ if (!opts.escapeHtml && line.includes('<')) {
668
+ const hit = this.findHtmlBlockStart(line);
669
+ if (hit) {
670
+ const before = line.slice(0, hit.index).trim();
671
+ if (before)
672
+ paragraph.push(before);
673
+ flush();
674
+ i = this.emitHtmlBlock(lines, i, hit, out);
675
+ continue;
676
+ }
677
+ }
678
+ // --- ATX heading -------------------------------------------------------
679
+ const heading = trimmed.match(/^(#{1,6})\s+(.+?)\s*#*\s*$/);
680
+ if (heading) {
681
+ flush();
682
+ const level = heading[1].length;
683
+ out.push(`<h${level}>${this.inline(heading[2], opts)}</h${level}>`);
684
+ continue;
685
+ }
686
+ // --- Horizontal rule ---------------------------------------------------
687
+ if (/^(?:-{3,}|\*{3,}|_{3,})$/.test(trimmed)) {
688
+ flush();
689
+ out.push('<hr>');
690
+ continue;
691
+ }
692
+ // --- Blockquote (consecutive lines, one level stripped, recursive) ------
693
+ if (/^>\s?/.test(trimmed)) {
694
+ flush();
695
+ const quoted = [];
696
+ while (i < lines.length && /^>\s?/.test(lines[i].trim())) {
697
+ quoted.push(lines[i].trim().replace(/^>\s?/, ''));
698
+ i++;
699
+ }
700
+ i--;
701
+ out.push(`<blockquote>${this.parseBlocks(quoted, opts)}</blockquote>`);
702
+ continue;
703
+ }
704
+ // --- Table (header row + separator row) ---------------------------------
705
+ if (trimmed.includes('|') &&
706
+ i + 1 < lines.length &&
707
+ lines[i + 1].includes('-') &&
708
+ /^\|?[\s:\-|]+\|?$/.test(lines[i + 1].trim())) {
709
+ flush();
710
+ const aligns = this.tableAligns(lines[i + 1].trim());
711
+ const head = this.tableRow(trimmed, 'th', aligns, opts);
712
+ const body = [];
713
+ i += 2;
714
+ while (i < lines.length && lines[i].trim().includes('|')) {
715
+ body.push(this.tableRow(lines[i].trim(), 'td', aligns, opts));
716
+ i++;
717
+ }
718
+ i--;
719
+ out.push(`<table>\n<thead>\n${head}\n</thead>` +
720
+ (body.length > 0 ? `\n<tbody>\n${body.join('\n')}\n</tbody>` : '') +
721
+ `\n</table>`);
722
+ continue;
723
+ }
724
+ // --- List ---------------------------------------------------------------
725
+ if (MarkdownUtils.listItemRe.test(line)) {
726
+ flush();
727
+ const [html, last] = this.parseList(lines, i, opts);
728
+ out.push(html);
729
+ i = last;
730
+ continue;
731
+ }
732
+ // --- Plain text: accumulate into current paragraph ----------------------
733
+ paragraph.push(trimmed);
938
734
  }
939
- // Fallback (non-secure contexts)
940
- return 'xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx'.replace(/[xy]/g, (c) => {
941
- const r = (Math.random() * 16) | 0;
942
- const v = c === 'x' ? r : (r & 0x3) | 0x8;
943
- return v.toString(16);
944
- });
945
- }
946
- /**
947
- * Reconstruct a standard UUID (with dashes) from a 32-char hex string without dashes.
948
- * @param value : 32-character hex string
949
- * @returns : the formatted UUID or the original string if it doesn't match the expected format
950
- */
951
- static inflateUUID(value) {
952
- const s = value.trim();
953
- if (s.length !== 32)
954
- return s;
955
- return `${s.slice(0, 8)}-${s.slice(8, 12)}-${s.slice(12, 16)}-${s.slice(16, 20)}-${s.slice(20)}`;
956
- }
957
- /** Precompiled validation patterns. */
958
- static { this.emailRegex = /^(?:[a-z0-9+!#$%&'*+\/=?^_`{|}~-]+(?:\.[a-z0-9!#$%&'*+\/=?^_`{|}~-]+)*|"(?:[\x01-\x08\x0b\x0c\x0e-\x1f\x21\x23-\x5b\x5d-\x7f]|\\[\x01-\x09\x0b\x0c\x0e-\x7f])*")@(?:(?:[a-z0-9](?:[a-z0-9-]*[a-z0-9])?\.)+[a-z0-9](?:[a-z0-9-]*[a-z0-9])?|\[(?:(?:25[0-5]|2[0-4][0-9]|[01]?[0-9][0-9]?)\.){3}(?:25[0-5]|2[0-4][0-9]|[01]?[0-9][0-9]?|[a-z0-9-]*[a-z0-9]:(?:[\x01-\x08\x0b\x0c\x0e-\x1f\x21-\x5a\x53-\x7f]|\\[\x01-\x09\x0b\x0c\x0e-\x7f])+)\])$/i; }
959
- static { this.urlRegex = /^(https?:\/\/)?([\da-z.-]+)\.([a-z.]{2,6})([/\w .-]*)\/?$/i; }
960
- /**
961
- * Parse a text and return true if it is a valid email (or empty)
962
- * @param value : email
963
- * @returns : true if the email is valid or empty
964
- */
965
- static parseEmail(value) {
966
- if (!value)
967
- return true;
968
- return this.emailRegex.test(value.trim().toLowerCase());
969
- }
970
- /**
971
- * Parse a text containing one or more email addresses separated by `;` or `,`
972
- * and return true if all of them are valid (or the text is empty).
973
- * Empty items caused by double or trailing separators are ignored.
974
- * @param value : the email list to parse (e.g. "a@b.it; c@d.it")
975
- * @returns : true if all emails are valid or the value is empty
976
- */
977
- static parseEmails(value) {
978
- if (!value || value.trim().length === 0)
979
- return true;
980
- const items = value
981
- .split(/[;,]/)
982
- .map(e => e.trim().toLowerCase())
983
- .filter(e => e.length > 0);
984
- if (items.length === 0)
985
- return false; // only separators, no addresses
986
- return items.every(e => this.emailRegex.test(e));
987
- }
988
- /**
989
- * Parse a text and return true if it is a valid url (or empty)
990
- * @param value : the url to parse
991
- * @returns : true if the url is valid or empty
992
- */
993
- static parseUrl(value) {
994
- if (!value)
995
- return true;
996
- return this.urlRegex.test(value.trim().toLowerCase());
735
+ flush();
736
+ return out.join('\n');
997
737
  }
738
+ // #endregion
739
+ // #region Lists
740
+ /** Matches "- item", "* item", "+ item", "1. item", "1) item" with leading indent. */
741
+ static { this.listItemRe = /^(\s*)([-*+]|\d+[.)])\s+(.*)$/; }
998
742
  /**
999
- * Get date parts from a string value
1000
- * @param value : the string to parse
1001
- * @returns : an array of numbers with year, month, day
743
+ * Gathers the contiguous list block starting at `start`, builds it (with
744
+ * nesting) and returns [html, indexOfLastConsumedLine].
1002
745
  */
1003
- static getDateParts(value) {
1004
- if (!value)
1005
- return undefined;
1006
- let parts = [];
1007
- if (value.indexOf("-") !== -1) {
1008
- const p = value.split("-");
1009
- if (p.length !== 3)
1010
- return undefined;
1011
- parts = [parseInt(p[0]), parseInt(p[1]) - 1, parseInt(p[2])];
1012
- }
1013
- else if (value.indexOf("/") !== -1) {
1014
- const p = value.split("/");
1015
- if (p.length !== 3)
1016
- return undefined;
1017
- parts = [parseInt(p[2]), parseInt(p[1]) - 1, parseInt(p[0])];
746
+ static parseList(lines, start, opts) {
747
+ const block = [];
748
+ let i = start;
749
+ while (i < lines.length) {
750
+ const l = lines[i];
751
+ if (!l.trim())
752
+ break; // blank line ends the list
753
+ if (this.listItemRe.test(l) || /^\s{2,}\S/.test(l)) {
754
+ block.push(l);
755
+ i++;
756
+ }
757
+ else
758
+ break;
1018
759
  }
1019
- return parts;
1020
- }
1021
- /**
1022
- * Parse a date
1023
- * @param value : the value to check
1024
- * @param locale : the locale to use
1025
- * @returns : a valid Date object or null
1026
- */
1027
- static parseDate(value, locale = it) {
1028
- // No value at all
1029
- if (!value)
1030
- return undefined;
1031
- // A Date value
1032
- if (value instanceof Date && value.getTime())
1033
- return value;
1034
- // Parse known formats using date-fns
1035
- let d = parseISO(value);
1036
- if (d && d.getTime() && d.getFullYear() > 1750)
1037
- return d;
1038
- d = parse(value, 'dd/MM/yyyy', new Date(), { locale: locale });
1039
- if (d && d.getTime() && d.getFullYear() > 1750)
1040
- return d;
1041
- d = parse(value, 'yyyy-MM-dd', new Date(), { locale: locale });
1042
- if (d && d.getTime() && d.getFullYear() > 1750)
1043
- return d;
1044
- // Parse values manually
1045
- const parts = this.getDateParts(value);
1046
- if (!parts)
1047
- return undefined;
1048
- if (parts[0] < 100)
1049
- parts[0] += 2000;
1050
- if (isNaN(parts[0]) || parts[0] < 0 || parts[0] < 1750)
1051
- return undefined;
1052
- if (isNaN(parts[1]) || parts[1] < 0)
1053
- return undefined;
1054
- if (isNaN(parts[2]) || parts[2] < 0)
1055
- return undefined;
1056
- if (parts[1] > 11)
1057
- return undefined;
1058
- if (parts[1] === 1 && parts[2] > 29)
1059
- return undefined;
1060
- else if (parts[1] !== 1 && parts[2] > 31)
1061
- return undefined;
1062
- return new TZDate(parts[0], parts[1], parts[2], 12);
760
+ return [this.buildList(block, opts), i - 1];
1063
761
  }
1064
- /**
1065
- * Format a date
1066
- * @param value : the date or string to format
1067
- * @param fmt : the DateFormat to use or the string pattern
1068
- * @param locale : the locale to use (default is IT)
1069
- * @returns : the formatted string
1070
- */
1071
- static formatDate(value, fmt = DateFormat.Short, locale = it) {
1072
- // No value at all
1073
- if (!value)
1074
- return '';
1075
- // A string
1076
- if (typeof value === 'string' || value instanceof String)
1077
- value = this.parseDate(value);
1078
- // Not a date
1079
- if (!(value instanceof Date && value.getTime()))
1080
- return '';
1081
- // Format
1082
- switch (fmt) {
1083
- case DateFormat.Short: return format(value, "dd/MM/yyyy", { locale: locale });
1084
- case DateFormat.Long: return format(value, "d MMMM yyyy", { locale: locale });
1085
- case DateFormat.LongWithShortMonth: return format(value, "d MMM yyyy", { locale: locale });
1086
- case DateFormat.LongWithWeekDay: return format(value, "EEEE, d MMMM yyyy", { locale: locale });
1087
- case DateFormat.LongWithShortWeekDay: return format(value, "EEE, d MMMM yyyy", { locale: locale });
1088
- case DateFormat.MonthAndYear: return format(value, "MMM yyyy", { locale: locale });
1089
- case DateFormat.LongMonthAndYear: return format(value, "MMMM yyyy", { locale: locale });
1090
- case DateFormat.WeekDay: return format(value, "EEE, d", { locale: locale });
1091
- case DateFormat.LongWeekDay: return format(value, "EEEE, d", { locale: locale });
1092
- case DateFormat.DayAndMonth: return format(value, "d MMMM", { locale: locale });
1093
- case DateFormat.ShortUS: return format(value, "MM/dd/yyyy", { locale: locale });
1094
- case DateFormat.ShortISO8601: return format(value, "yyyy-MM-dd", { locale: locale });
1095
- default:
1096
- return format(value, fmt, { locale: locale });
762
+ static buildList(block, opts) {
763
+ const first = block[0].match(this.listItemRe);
764
+ const baseIndent = first[1].length;
765
+ const ordered = /^\d/.test(first[2]);
766
+ const startNum = ordered ? parseInt(first[2], 10) : 1;
767
+ const items = [];
768
+ for (const l of block) {
769
+ const m = l.match(this.listItemRe);
770
+ if (m && m[1].length <= baseIndent) {
771
+ items.push({ text: m[3], childLines: [] });
772
+ }
773
+ else if (items.length > 0) {
774
+ // Continuation or nested content: strip one nesting level of indent
775
+ items[items.length - 1].childLines.push(l.length > baseIndent + 2 ? l.slice(baseIndent + 2) : l.trim());
776
+ }
1097
777
  }
1098
- }
1099
- /**
1100
- * Return an italian local date
1101
- * @param value : the date
1102
- * @returns : the new date
1103
- */
1104
- static toLocalDate(value) {
1105
- // No value at all
1106
- if (!value)
1107
- return undefined;
1108
- // A string
1109
- if (typeof value === 'string' || value instanceof String)
1110
- value = this.parseDate(value);
1111
- // Not a date
1112
- if (!(value instanceof Date && value.getTime()))
1113
- return undefined;
1114
- // Update date
1115
- return new TZDate(value, "Europe/Rome");
1116
- }
1117
- /**
1118
- * Update a DateInterval object according to a string
1119
- * @param value : string value
1120
- * @param interval : DateInterval value to update
1121
- * @param end : true if must be updated the first or the end value
1122
- * @param copy : copy the same value (works only if not end element)
1123
- */
1124
- static changeDateInterval(value, interval, end = false, copy = false) {
1125
- if (value) {
1126
- let year = -1;
1127
- if (value.length === 4 && (year = parseInt(value)) > 1750) {
1128
- if (!end) {
1129
- interval.from = new TZDate(new Date(year, 0, 1), 'Europe/Rome');
1130
- interval.to = new TZDate(new Date(year, 11, 31), 'Europe/Rome');
1131
- }
1132
- else
1133
- interval.to = new Date(year, 11, 31);
778
+ const lis = items.map(item => {
779
+ let inner;
780
+ // GFM task list: - [ ] / - [x]
781
+ const task = item.text.match(/^\[([ xX])\]\s+(.*)$/);
782
+ if (task) {
783
+ const checked = task[1] !== ' ' ? ' checked' : '';
784
+ inner = `<input type="checkbox" disabled${checked}> ${this.inline(task[2], opts)}`;
1134
785
  }
1135
786
  else {
1136
- let parts = this.getDateParts(value);
1137
- if (!parts || isNaN(parts[0]) || parts[0] < 0 || parts[0] < 1750)
1138
- return;
1139
- const d = new TZDate(new Date(parts[0], parts[1], parts[2]), 'Europe/Rome');
1140
- if (end)
1141
- interval.to = d;
1142
- else if (copy) {
1143
- interval.from = d;
1144
- interval.to = d;
1145
- }
1146
- else
1147
- interval.from = d;
787
+ inner = this.inline(item.text, opts);
1148
788
  }
1149
- }
1150
- }
1151
- /**
1152
- * Formats a number using `Intl.NumberFormat`.
1153
- * @param value - The number to format.
1154
- * @param decimals - Maximum decimal places (default: `2`).
1155
- * @param locale - BCP 47 locale tag (default: `'it-IT'`).
1156
- * @returns The formatted number string.
1157
- */
1158
- static formatNumber(value, decimals = 2, locale = 'it-IT') {
1159
- return Intl.NumberFormat(locale, { style: 'decimal', minimumFractionDigits: 0, maximumFractionDigits: decimals }).format(value);
789
+ if (item.childLines.length > 0) {
790
+ inner += '\n' + this.parseBlocks(item.childLines, opts);
791
+ }
792
+ return `<li>${inner}</li>`;
793
+ });
794
+ const tag = ordered ? 'ol' : 'ul';
795
+ const startAttr = ordered && startNum > 1 ? ` start="${startNum}"` : '';
796
+ return `<${tag}${startAttr}>\n${lis.join('\n')}\n</${tag}>`;
1160
797
  }
1161
- /**
1162
- * Formats a number as a currency string using `Intl.NumberFormat`.
1163
- * @param value - The number to format.
1164
- * @param currency - ISO 4217 currency code (default: `'EUR'`).
1165
- * @param decimals - Maximum decimal places (default: `2`).
1166
- * @param locale - BCP 47 locale tag (default: `'it-IT'`).
1167
- * @returns The formatted currency string.
1168
- */
1169
- static formatCurrency(value, currency = 'EUR', decimals = 2, locale = 'it-IT') {
1170
- return Intl.NumberFormat(locale, { style: 'currency', currency, minimumFractionDigits: 0, maximumFractionDigits: decimals }).format(value);
798
+ // #endregion
799
+ // #region Tables
800
+ static tableAligns(separator) {
801
+ return separator
802
+ .replace(/^\||\|$/g, '')
803
+ .split('|')
804
+ .map(cell => {
805
+ const t = cell.trim();
806
+ if (t.startsWith(':') && t.endsWith(':'))
807
+ return 'center';
808
+ if (t.endsWith(':'))
809
+ return 'right';
810
+ if (t.startsWith(':'))
811
+ return 'left';
812
+ return undefined;
813
+ });
1171
814
  }
1172
- /**
1173
- * Percent-encodes a string for safe inclusion in a URL.
1174
- * @param value - The string to encode.
1175
- * @returns The encoded string, or `undefined` when `value` is empty.
1176
- */
1177
- static urlEncode(value) {
1178
- return value.length > 0
1179
- ? encodeURIComponent(value)
1180
- : undefined;
815
+ static tableRow(row, tag, aligns, opts) {
816
+ const cells = row.replace(/^\||\|$/g, '').split('|').map(c => c.trim());
817
+ const cellsHtml = cells
818
+ .map((cell, j) => {
819
+ const align = aligns[j] ? ` align="${aligns[j]}"` : '';
820
+ return `<${tag}${align}>${this.inline(cell, opts)}</${tag}>`;
821
+ })
822
+ .join('');
823
+ return `<tr>${cellsHtml}</tr>`;
1181
824
  }
825
+ // #endregion
826
+ // #region Inline formatting
1182
827
  /**
1183
- * Decodes a percent-encoded URL string, treating `+` as a space.
1184
- * @param value - The encoded string to decode.
1185
- * @returns The decoded string, or `undefined` when `value` is empty or absent.
828
+ * Applies inline markdown to a single text segment.
829
+ * Generated HTML (code, links, images, autolinks) is stashed behind \u0000
830
+ * placeholders so later regex passes can never corrupt it.
1186
831
  */
1187
- static urlDecode(value) {
1188
- return value && value.length > 0
1189
- ? decodeURIComponent(value.replace(/\+/g, '%20'))
1190
- : undefined;
832
+ static inline(text, opts) {
833
+ const store = [];
834
+ const stash = (html) => `\u0000${store.push(html) - 1}\u0000`;
835
+ // 1. Inline code: stash first so later passes can't touch it.
836
+ // Content is ALWAYS escaped so <, >, & inside code survive in the DOM
837
+ // (the browser renders entities back to the original characters).
838
+ let s = text.replace(/`([^`]+)`/g, (_m, code) => stash(`<code>${this.escape(code)}</code>`));
839
+ // 2. Escape raw HTML as entities (opt-in only)
840
+ if (opts.escapeHtml)
841
+ s = this.escape(s);
842
+ // 3. Images: ![alt](url "title")
843
+ s = s.replace(/!\[([^\]]*)\]\(\s*([^)\s]+)(?:\s+(?:"|&quot;)(.+?)(?:"|&quot;))?\s*\)/g, (_m, alt, src, title) => {
844
+ const url = this.safeUrl(src);
845
+ if (!url)
846
+ return alt;
847
+ const t = title ? ` title="${this.escapeAttr(title)}"` : '';
848
+ return stash(`<img src="${url}" alt="${this.escapeAttr(alt)}"${t} loading="lazy">`);
849
+ });
850
+ // 4. Links: [text](url "title")
851
+ s = s.replace(/\[([^\]]+)\]\(\s*([^)\s]+)(?:\s+(?:"|&quot;)(.+?)(?:"|&quot;))?\s*\)/g, (_m, txt, href, title) => {
852
+ const url = this.safeUrl(href);
853
+ if (!url)
854
+ return txt;
855
+ const t = title ? ` title="${this.escapeAttr(title)}"` : '';
856
+ return stash(`<a href="${url}"${t} target="_blank" rel="noopener noreferrer">${txt}</a>`);
857
+ });
858
+ // 5. Autolink remaining bare URLs (markdown links are already stashed)
859
+ s = s.replace(/https?:\/\/[^\s<>"']+/g, url => stash(`<a href="${url}" target="_blank" rel="noopener noreferrer">${url}</a>`));
860
+ // 6. Bold / italic / strikethrough.
861
+ // Delimiters must hug the text; underscore emphasis only at word
862
+ // boundaries so identifiers like snake_case are left untouched.
863
+ s = s.replace(/\*\*\*(?=\S)([\s\S]*?\S)\*\*\*/g, '<strong><em>$1</em></strong>');
864
+ s = s.replace(/(?<![\w*])___(?=\S)([\s\S]*?\S)___(?![\w*])/g, '<strong><em>$1</em></strong>');
865
+ s = s.replace(/\*\*(?=\S)([\s\S]*?\S)\*\*/g, '<strong>$1</strong>');
866
+ s = s.replace(/(?<![\w*])__(?=\S)([\s\S]*?\S)__(?![\w*])/g, '<strong>$1</strong>');
867
+ s = s.replace(/\*(?=\S)([^*\n]*?\S)\*/g, '<em>$1</em>');
868
+ s = s.replace(/(?<![\w_])_(?=\S)([^_\n]*?\S)_(?![\w_])/g, '<em>$1</em>');
869
+ s = s.replace(/~~(?=\S)([\s\S]*?\S)~~/g, '<del>$1</del>');
870
+ // 7. Restore stashed HTML
871
+ return s.replace(/\u0000(\d+)\u0000/g, (_m, idx) => store[Number(idx)] ?? '');
872
+ }
873
+ // #endregion
874
+ // #region Security helpers
875
+ /** Escapes quotes for safe interpolation inside a double-quoted HTML attribute. */
876
+ static escapeAttr(s) {
877
+ return s.replace(/&(?!(?:amp|lt|gt|quot|#\d+);)/g, '&amp;').replace(/"/g, '&quot;');
878
+ }
879
+ /** Escapes &, <, > and " for safe HTML interpolation. */
880
+ static escape(s) {
881
+ return s
882
+ .replace(/&/g, '&amp;')
883
+ .replace(/</g, '&lt;')
884
+ .replace(/>/g, '&gt;')
885
+ .replace(/"/g, '&quot;');
1191
886
  }
1192
887
  /**
1193
- * Reads a query string parameter value from the current page URL.
1194
- * @param name - The parameter name to look up.
1195
- * @returns The decoded parameter value, or `undefined` when absent or running server-side.
888
+ * Returns a sanitized URL or undefined when the scheme is dangerous.
889
+ * Blocks javascript:, vbscript: and data: (also when obfuscated with
890
+ * whitespace/control characters, e.g. "java\tscript:").
1196
891
  */
1197
- static getQueryStringValueByName(name) {
1198
- if (!this.isBrowser())
892
+ static safeUrl(url) {
893
+ const compact = url.replace(/[\s\u0000-\u001f]/g, '');
894
+ if (/^(javascript|vbscript|data):/i.test(compact))
1199
895
  return undefined;
1200
- const v = new URLSearchParams(window.location.search).get(name);
1201
- return v && v !== 'null' ? v : undefined;
896
+ return url.replace(/"/g, '%22');
1202
897
  }
898
+ // #endregion
899
+ // #region Clipboard
1203
900
  /**
1204
- * Generate a password
1205
- * @returns : the password string
901
+ * Copies markdown content to the clipboard in two flavors:
902
+ * - text/html : the rendered HTML (rich paste into Word, Outlook, Gmail...)
903
+ * - text/plain : the original markdown source (paste into editors, IDEs...)
904
+ * @param markdown : the markdown source
905
+ * @param options : conversion options for the HTML flavor
906
+ * @returns : true on success
1206
907
  */
1207
- static generatePassword() {
1208
- const random = "$" + Math.random().toString(36).slice(-11);
1209
- let password = "";
1210
- let hasUpperCase = false;
1211
- for (const rnd of random) {
1212
- if (!hasUpperCase && "abcdefghjilmnopqrstuvywxz".includes(rnd)) {
1213
- password += rnd.toUpperCase();
1214
- hasUpperCase = true;
908
+ static async copyToClipboard(markdown, options) {
909
+ if (!markdown || typeof navigator === 'undefined' || !navigator.clipboard)
910
+ return false;
911
+ try {
912
+ if (typeof ClipboardItem !== 'undefined') {
913
+ const html = this.toHtml(markdown, options);
914
+ await navigator.clipboard.write([
915
+ new ClipboardItem({
916
+ 'text/html': new Blob([html], { type: 'text/html' }),
917
+ 'text/plain': new Blob([markdown], { type: 'text/plain' }),
918
+ }),
919
+ ]);
1215
920
  }
1216
921
  else {
1217
- password += rnd;
922
+ // Older engines: plain text only
923
+ await navigator.clipboard.writeText(markdown);
1218
924
  }
925
+ return true;
926
+ }
927
+ catch {
928
+ // Clipboard API requires a secure context and user activation
929
+ return false;
1219
930
  }
1220
- if (!hasUpperCase)
1221
- password = password.substring(0, 11) + "M";
1222
- return password;
1223
931
  }
1224
932
  /**
1225
- * Calculate password strength
1226
- * @param password: the password to evaluate
1227
- * @returns the password strength info
933
+ * Copies plain text (e.g. the content of a single code block) to the clipboard.
934
+ * @param text : the text to copy
935
+ * @returns : true on success
1228
936
  */
1229
- static calculatePasswordStrength(password) {
1230
- if (password && password.length > 0) {
1231
- let score = 0;
1232
- const suggestions = [];
1233
- // Length
1234
- if (password.length >= 10)
1235
- score++;
1236
- else
1237
- suggestions.push('Usa almeno 10 caratteri.');
1238
- if (password.length >= 12)
1239
- score++;
1240
- else if (password.length >= 10)
1241
- suggestions.push('Considera di usare più di 12 caratteri.');
1242
- // Lowercase letters
1243
- if (/[a-z]/.test(password))
1244
- score++;
1245
- else
1246
- suggestions.push('Aggiungi lettere minuscole.');
1247
- // Uppercase letters
1248
- if (/[A-Z]/.test(password))
1249
- score++;
1250
- else
1251
- suggestions.push('Aggiungi lettere maiuscole.');
1252
- // Numbers
1253
- if (/\d/.test(password))
1254
- score++;
1255
- else
1256
- suggestions.push('Aggiungi numeri.');
1257
- // Special characters
1258
- if (/[!@#$%^&*()_+\-=\[\]{};':"\\|,.<>\/?]/.test(password))
1259
- score++;
1260
- else
1261
- suggestions.push('Aggiungi caratteri speciali (!@#$%^&*).');
1262
- // Common patterns
1263
- if (/(.)\1{2,}/.test(password)) {
1264
- score = Math.max(0, score - 1);
1265
- suggestions.push('Evita di ripetere lo stesso carattere.');
1266
- }
1267
- if (/123|abc|qwe/i.test(password)) {
1268
- score = Math.max(0, score - 1);
1269
- suggestions.push('Evita sequenze comuni (123, abc, qwe).');
1270
- }
1271
- // Label and color
1272
- let label;
1273
- let color;
1274
- let isValid;
1275
- if (score <= 2) {
1276
- label = 'Molto debole';
1277
- color = '#f44336';
1278
- isValid = false;
1279
- }
1280
- else if (score <= 3) {
1281
- label = 'Debole';
1282
- color = '#ff9800';
1283
- isValid = false;
1284
- }
1285
- else if (score <= 4) {
1286
- label = 'Media';
1287
- color = '#ffc107';
1288
- isValid = true;
1289
- }
1290
- else if (score <= 5) {
1291
- label = 'Forte';
1292
- color = '#8bc34a';
1293
- isValid = true;
1294
- }
1295
- else {
1296
- label = 'Molto forte';
1297
- color = '#4caf50';
1298
- isValid = true;
1299
- }
1300
- return { score, label, color, suggestions, isValid };
937
+ static async copyText(text) {
938
+ if (!text || typeof navigator === 'undefined' || !navigator.clipboard)
939
+ return false;
940
+ try {
941
+ await navigator.clipboard.writeText(text);
942
+ return true;
943
+ }
944
+ catch {
945
+ return false;
1301
946
  }
1302
- else
1303
- return {
1304
- score: 0,
1305
- isValid: false,
1306
- suggestions: []
1307
- };
1308
947
  }
1309
948
  /**
1310
- * Check if current browser supports touch
1311
- * @returns : true if the display is touchable
949
+ * Extracts the visible plain text from a rendered element
950
+ * (what the user sees, entities already decoded by the browser).
951
+ * @param element : the element hosting the rendered markdown
952
+ * @returns : the plain text
1312
953
  */
1313
- static isTouchable() {
1314
- return (this.isBrowser() && ("ontouchstart" in window ||
1315
- navigator.maxTouchPoints > 0));
954
+ static elementToText(element) {
955
+ return element.innerText ?? element.textContent ?? '';
1316
956
  }
957
+ }
958
+
959
+ var DateFormat;
960
+ (function (DateFormat) {
961
+ DateFormat[DateFormat["Short"] = 1] = "Short";
962
+ DateFormat[DateFormat["Long"] = 2] = "Long";
963
+ DateFormat[DateFormat["LongWithShortMonth"] = 3] = "LongWithShortMonth";
964
+ DateFormat[DateFormat["LongWithWeekDay"] = 4] = "LongWithWeekDay";
965
+ DateFormat[DateFormat["LongWithShortWeekDay"] = 5] = "LongWithShortWeekDay";
966
+ DateFormat[DateFormat["MonthAndYear"] = 6] = "MonthAndYear";
967
+ DateFormat[DateFormat["LongMonthAndYear"] = 7] = "LongMonthAndYear";
968
+ DateFormat[DateFormat["WeekDay"] = 8] = "WeekDay";
969
+ DateFormat[DateFormat["LongWeekDay"] = 9] = "LongWeekDay";
970
+ DateFormat[DateFormat["DayAndMonth"] = 10] = "DayAndMonth";
971
+ DateFormat[DateFormat["ShortUS"] = 11] = "ShortUS";
972
+ DateFormat[DateFormat["ShortISO8601"] = 12] = "ShortISO8601"; // yyyy-mm-dd
973
+ })(DateFormat || (DateFormat = {}));
974
+ class SystemUtils {
975
+ /** Shared collator for locale-aware, case-insensitive string comparison. */
976
+ static { this.collator = new Intl.Collator('it', { sensitivity: 'base', numeric: true }); }
1317
977
  /**
1318
- * This check will prevent 'window' logic to be executed
1319
- * while executing the server rendering
1320
- * @returns : true if using the browser
978
+ * Array find by key
979
+ * @param array : the array to scan
980
+ * @param key : key name
981
+ * @param value : the value to search for
982
+ * @returns : the property value or null
1321
983
  */
1322
- static isBrowser() {
1323
- return typeof (window) !== 'undefined';
984
+ static arrayFindByKey(array, key, value) {
985
+ if (!array)
986
+ return undefined;
987
+ for (const item of array) {
988
+ if (item[key] === value) {
989
+ return item;
990
+ }
991
+ }
992
+ return undefined;
993
+ }
994
+ /**
995
+ * Array find index by key
996
+ * @param array : the array to scan
997
+ * @param key : the key name
998
+ * @param value : the value to search for
999
+ * @returns : the array index or -1 if not found
1000
+ */
1001
+ static arrayFindIndexByKey(array, key, value) {
1002
+ if (!array)
1003
+ return -1;
1004
+ for (let i = 0; i < array.length; i++) {
1005
+ if (array[i][key] === value) {
1006
+ return i;
1007
+ }
1008
+ }
1009
+ return -1;
1324
1010
  }
1325
1011
  /**
1326
- * Convert folders in a tree of Node object.
1327
- * @param folders : the subfolders group or null to root
1328
- * @returns : a node list
1012
+ * Get a value from and array made of name|value items
1013
+ * @param array : the array to scan
1014
+ * @param value : the value to search for
1015
+ * @returns : the property value or null if not found
1329
1016
  */
1330
- static toNodes(folders) {
1331
- return this._toNodes(folders, undefined);
1017
+ static arrayGetValue(array, value) {
1018
+ if (!array)
1019
+ return undefined;
1020
+ for (const item of array) {
1021
+ const i = item;
1022
+ if (i['value'] === value) {
1023
+ return i['name'] ?? i['id'];
1024
+ }
1025
+ }
1026
+ return undefined;
1332
1027
  }
1333
1028
  /**
1334
- * Convert folders in a tree of Node object.
1335
- * @param folders : the children group or null to root
1336
- * @param parent : the parent node
1337
- * @returns : a node list
1029
+ * Convert items to nodes into a tree structure
1030
+ * @param items : list of nodes
1031
+ * @param parent : parent node
1032
+ * @returns : an array of INode objects
1338
1033
  */
1339
- static _toNodes(folders, parent) {
1034
+ static arrayToNodes(items, parent) {
1340
1035
  const nodes = [];
1341
- folders.forEach((n) => {
1036
+ items.forEach(n => {
1342
1037
  const node = {
1343
- id: n.id,
1038
+ id: n.id.toString(),
1344
1039
  name: n.name,
1345
- count: n.count,
1346
- parent: parent,
1040
+ count: n.count ? n.count : 0,
1347
1041
  children: undefined,
1042
+ parent,
1348
1043
  bag: n,
1349
1044
  };
1350
- const children = n.children ?? n.subFolders ?? [];
1351
- if (children.length > 0) {
1352
- node.children = this._toNodes(children, node);
1353
- }
1354
1045
  nodes.push(node);
1046
+ node.children =
1047
+ n.children && n.children.length > 0
1048
+ ? this.arrayToNodes(n.children, node)
1049
+ : [];
1355
1050
  });
1356
1051
  return nodes;
1357
1052
  }
1358
1053
  /**
1359
- * Returns an array of individual power-of-2 flag values that are set in `value`.
1360
- * @param value - The bitmask to decompose.
1361
- * @param max - Upper-bound exponent: checks flags from `1` up to `1 << max` (default: `30`).
1362
- * @returns Array of set flag values, or an empty array when `value` is `0`.
1054
+ * Comparator factory for sorting arrays of objects by a given property key.
1055
+ * Pass the element type for compile-time key checking:
1056
+ * `items.sort(SystemUtils.arraySortCompare<Employee>('lastName'))`.
1057
+ * @param key - Name of the property to sort by (autocompleted from `T` when provided).
1058
+ * @param order - Sort direction: `'asc'` (default) or `'desc'`.
1059
+ * @returns A comparator function that returns a negative, zero, or positive number.
1363
1060
  */
1364
- static getFlags(value, max = 30) {
1365
- if (value !== 0) {
1366
- const items = [];
1367
- let v = 1;
1368
- const m = 1 << max;
1369
- while (v < m) {
1370
- if ((value & v) === v)
1371
- items.push(v);
1372
- v = v << 1;
1061
+ static arraySortCompare(key, order = 'asc') {
1062
+ const dir = order === 'desc' ? -1 : 1;
1063
+ return (a, b) => {
1064
+ const varA = a[key];
1065
+ const varB = b[key];
1066
+ if (varA === varB)
1067
+ return 0;
1068
+ if (varA === undefined || varA === null)
1069
+ return -1 * dir;
1070
+ if (varB === undefined || varB === null)
1071
+ return 1 * dir;
1072
+ if (typeof varA === 'string' && typeof varB === 'string') {
1073
+ return SystemUtils.collator.compare(varA, varB) * dir;
1373
1074
  }
1374
- return items;
1375
- }
1075
+ return (varA > varB ? 1 : varA < varB ? -1 : 0) * dir;
1076
+ };
1077
+ }
1078
+ /**
1079
+ * Format weight
1080
+ * @param gr : grams
1081
+ * @returns : the formatted string
1082
+ */
1083
+ static formatWeight(gr) {
1084
+ if (gr > 1000000)
1085
+ return `${(gr / 1000000).toFixed(2)} t`;
1086
+ else if (gr > 100000)
1087
+ return `${(gr / 100000).toFixed(2)} q`;
1088
+ else if (gr > 1000)
1089
+ return `${(gr / 1000).toFixed(2)} kg`;
1376
1090
  else
1377
- return [];
1091
+ return `${gr} gr`;
1378
1092
  }
1379
- /** Cache for resolved color luminance results. */
1380
- static { this.colorLightCache = new Map(); }
1381
1093
  /**
1382
- * Check if a color is light or dark
1383
- * @param color : the color
1384
- * @param minimumLuminance : the lumimance to consider
1385
- * @returns true if the color is light
1094
+ * Format file size
1095
+ * @param bytes : number of bytes
1096
+ * @returns : the formatted string
1386
1097
  */
1387
- static isColorLight(color, minimumLuminance = 186) {
1388
- if (!this.isBrowser())
1389
- return true; // SSR fallback
1390
- const cacheKey = `${color}|${minimumLuminance}`;
1391
- const cached = this.colorLightCache.get(cacheKey);
1392
- if (cached !== undefined)
1393
- return cached;
1394
- const tempDiv = document.createElement('div');
1395
- tempDiv.style.color = color;
1396
- document.body.appendChild(tempDiv);
1397
- const rgb = getComputedStyle(tempDiv).color;
1398
- document.body.removeChild(tempDiv);
1399
- const result = rgb.match(/\d+/g);
1400
- let light = true; // fallback
1401
- if (result && result.length >= 3) {
1402
- const r = parseInt(result[0], 10);
1403
- const g = parseInt(result[1], 10);
1404
- const b = parseInt(result[2], 10);
1405
- light = (0.299 * r + 0.587 * g + 0.114 * b) > minimumLuminance;
1098
+ static formatFileSize(bytes) {
1099
+ const MB = 1024 * 1024;
1100
+ if (bytes >= MB)
1101
+ return `${(bytes / MB).toFixed(1)} MB`;
1102
+ if (bytes >= 1024)
1103
+ return `${(bytes / 1024).toFixed(1)} KB`;
1104
+ return `${bytes} byte`;
1105
+ }
1106
+ /**
1107
+ * Compare two strings (case-insensitive, locale-aware, accent-insensitive).
1108
+ * @param a : string a
1109
+ * @param b : string b
1110
+ * @returns : 0 if equals, 1 if bigger, -1 if lower
1111
+ */
1112
+ static compareString(a, b) {
1113
+ return this.collator.compare(a ?? '', b ?? '');
1114
+ }
1115
+ /**
1116
+ * Capitalize a string
1117
+ * @param s : the string to capitalize
1118
+ * @returns : the capitalized string
1119
+ */
1120
+ static capitalize(s) {
1121
+ if (!s)
1122
+ return undefined;
1123
+ let b = "";
1124
+ let cap = true;
1125
+ for (const char of s) {
1126
+ if (char === " ") {
1127
+ b += char;
1128
+ cap = true;
1129
+ }
1130
+ else if (cap) {
1131
+ b += char.toUpperCase();
1132
+ cap = false;
1133
+ }
1134
+ else {
1135
+ b += char.toLowerCase();
1136
+ }
1406
1137
  }
1407
- this.colorLightCache.set(cacheKey, light);
1408
- return light;
1138
+ return b;
1139
+ }
1140
+ /**
1141
+ * Truncate a string at the last word boundary before `max`.
1142
+ * @param s : the string to truncate
1143
+ * @param max : the max number of chars
1144
+ * @returns : the truncated string
1145
+ */
1146
+ static truncate(s, max = 500) {
1147
+ if (!s)
1148
+ return undefined;
1149
+ if (s.length <= max)
1150
+ return s;
1151
+ const i = s.lastIndexOf(' ', max - 1);
1152
+ return i > 0 ? s.slice(0, i) : s;
1153
+ }
1154
+ /**
1155
+ * Join a list of strings
1156
+ * @param items : the list of strings
1157
+ * @param sep : the separator string
1158
+ * @param max : the maximum resulting string
1159
+ * @returns : the joined string
1160
+ */
1161
+ static join(items, sep = " ", max = 350) {
1162
+ if (!items || items.length === 0)
1163
+ return undefined;
1164
+ if (items.length > 1) {
1165
+ let l = 0;
1166
+ let s = "";
1167
+ while (s.length < max && items.length > l) {
1168
+ if (l > 0) {
1169
+ s += sep;
1170
+ }
1171
+ s += items[l++];
1172
+ }
1173
+ if (items.length > l) {
1174
+ s += "...";
1175
+ }
1176
+ return s;
1177
+ }
1178
+ return items[0];
1179
+ }
1180
+ /**
1181
+ * Normalize a string by converting it to lowercase and removing extra spaces, with special handling for acronyms and camelCase.
1182
+ * @param s : the string to normalize
1183
+ * @returns : The normalized string, or `undefined` if the input is falsy.
1184
+ */
1185
+ static normalizeDisplay(s) {
1186
+ if (!s)
1187
+ return s;
1188
+ return s
1189
+ .split(' ')
1190
+ .map((word, wordIndex) => {
1191
+ if (!word)
1192
+ return word;
1193
+ // If the word is all uppercase and contains at least one letter, we assume it's an acronym and leave it as is
1194
+ if (word === word.toUpperCase() && /[A-Z]/.test(word)) {
1195
+ return word;
1196
+ }
1197
+ // Otherwise, convert to lowercase letter by letter
1198
+ const chars = word.split('');
1199
+ return chars
1200
+ .map((char, charIndex) => {
1201
+ if (charIndex === 0 && wordIndex === 0) {
1202
+ // First letter of the first word:
1203
+ // remains uppercase only if the first 2 letters are both uppercase
1204
+ const secondChar = chars[1] ?? '';
1205
+ const keepUppercase = char === char.toUpperCase() &&
1206
+ secondChar === secondChar.toUpperCase() &&
1207
+ /[A-Z]/.test(char) &&
1208
+ /[A-Z]/.test(secondChar);
1209
+ return keepUppercase ? char : char.toLowerCase();
1210
+ }
1211
+ return char.toLowerCase();
1212
+ })
1213
+ .join('');
1214
+ })
1215
+ .join(' ');
1216
+ }
1217
+ /**
1218
+ * Wraps bare URLs in the given string with `<a>` anchor tags.
1219
+ * @param s - The plain-text or HTML string to process.
1220
+ * @returns The string with URLs replaced by clickable links, or `''` when `s` is falsy.
1221
+ */
1222
+ static replaceAsHtml(s) {
1223
+ if (!s)
1224
+ return '';
1225
+ return s.replace(/https?:\/\/[^\s<]+/gi, v => `<a href="${v}" target="_blank" rel="noopener">${v}</a>`);
1226
+ }
1227
+ /**
1228
+ * Convert markdown to html
1229
+ * @param markdown : the markdown data
1230
+ * @param escapeHtml : true to escape HTML. Default is false
1231
+ * @returns the html
1232
+ */
1233
+ static markdownToHtml(markdown, escapeHtml = false) {
1234
+ return MarkdownUtils.toHtml(markdown, { escapeHtml: escapeHtml });
1409
1235
  }
1410
- }
1411
-
1412
- /**
1413
- * General-purpose formatting pipe that converts a raw value to a locale-aware string
1414
- * based on the specified format type.
1415
- *
1416
- * Supported types: `'date'` / `'D'`, `'currency'` / `'C'`, `'number'` / `'N'`,
1417
- * `'number0'` / `'N0'`, `'percentage'` / `'P'`.
1418
- *
1419
- * Usage: `{{ value | format:'currency' }}`
1420
- */
1421
- class FormatPipe {
1422
1236
  /**
1423
- * Formats a value according to the specified type and optional pattern.
1424
- * Returns `undefined` when the value is `null` or `undefined`, or when the type is unrecognised.
1425
- * @param value - The raw value to format.
1426
- * @param type - The format type identifier (default: `'date'`).
1427
- * @param pattern - The date pattern used when `type` is `'date'` (default: `'dd/MM/yyyy'`).
1428
- * @returns A formatted string, or `undefined` when the value cannot be formatted.
1237
+ * Compare two names
1238
+ * @param a : name a
1239
+ * @param b : name b
1240
+ * @returns : true if a equals b
1429
1241
  */
1430
- transform(value, type = 'date', pattern = 'dd/MM/yyyy') {
1431
- if (value === undefined || value === null)
1432
- return undefined;
1433
- switch (type) {
1434
- case 'D':
1435
- case 'date': {
1436
- const d = SystemUtils.parseDate(value, it);
1437
- if (d)
1438
- return format(d, pattern, { locale: it });
1439
- break;
1242
+ static compareNames(a, b) {
1243
+ if (a)
1244
+ a = a.trim();
1245
+ if (b)
1246
+ b = b.trim();
1247
+ if (a && b && a.length !== b.length)
1248
+ return false;
1249
+ if (this.compareString(a, b) === 0)
1250
+ return true;
1251
+ const p1 = (a ?? '').split(' ');
1252
+ const p2 = (b ?? '').split(' ');
1253
+ if (p1.length !== p2.length)
1254
+ return false;
1255
+ let matches = p1.length;
1256
+ const used = new Array(p2.length).fill(false);
1257
+ for (const s1 of p1) {
1258
+ for (let j = 0; j < p2.length; j++) {
1259
+ if (!used[j] && this.compareString(s1, p2[j]) === 0) {
1260
+ used[j] = true;
1261
+ matches--;
1262
+ break;
1263
+ }
1440
1264
  }
1441
- case 'C':
1442
- case 'currency':
1443
- return new Intl.NumberFormat('it-IT', { style: 'currency', currency: 'EUR' }).format(value);
1444
- case 'N':
1445
- case 'number':
1446
- return new Intl.NumberFormat('it-IT', { style: 'decimal', minimumFractionDigits: 0, maximumFractionDigits: 2 }).format(value);
1447
- case 'N0':
1448
- case 'number0':
1449
- return new Intl.NumberFormat('it-IT', { style: 'decimal', minimumFractionDigits: 0, maximumFractionDigits: 0 }).format(value);
1450
- case 'P':
1451
- case 'percentage':
1452
- return new Intl.NumberFormat('it-IT', { style: 'percent', minimumFractionDigits: 0, maximumFractionDigits: 0 }).format(value);
1453
1265
  }
1454
- return undefined;
1455
- }
1456
- static { this.ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "22.0.1", ngImport: i0, type: FormatPipe, deps: [], target: i0.ɵɵFactoryTarget.Pipe }); }
1457
- static { this.ɵpipe = i0.ɵɵngDeclarePipe({ minVersion: "14.0.0", version: "22.0.1", ngImport: i0, type: FormatPipe, isStandalone: true, name: "format" }); }
1458
- }
1459
- i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "22.0.1", ngImport: i0, type: FormatPipe, decorators: [{
1460
- type: Pipe,
1461
- args: [{
1462
- name: 'format',
1463
- standalone: true
1464
- }]
1465
- }] });
1466
-
1467
- /**
1468
- * Pipe that applies a global regex replacement on a string and returns the result
1469
- * as sanitized HTML. When `regexValue` is `'\n'` and no `replaceValue` is given,
1470
- * newlines are replaced with `<br>` tags.
1471
- *
1472
- * Usage: `{{ text | replace:'\n':'' }}`
1473
- */
1474
- class ReplacePipe {
1475
- constructor() {
1476
- this.sanitizer = inject(DomSanitizer);
1266
+ return matches === 0;
1477
1267
  }
1478
1268
  /**
1479
- * Replaces all occurrences of `regexValue` in `value` with `replaceValue`.
1480
- * Returns `undefined` when `value` is empty or `undefined`.
1481
- * @param value - The source string to process.
1482
- * @param regexValue - The regex pattern string to match (applied with the global flag).
1483
- * @param replaceValue - The replacement string. Defaults to `'<br>'` when `regexValue` is `'\n'` and this is falsy.
1484
- * @returns A `SafeHtml` value with all matches replaced, or `undefined` when the input is empty.
1269
+ * Obfuscate a text with a key-driven code point rotation.
1270
+ *
1271
+ * @deprecated This is OBFUSCATION, not cryptography: it hides values from
1272
+ * casual inspection but offers no real security. Use {@link encrypt} /
1273
+ * {@link decrypt} (AES-GCM via Web Crypto) for anything sensitive.
1274
+ *
1275
+ * The rotation operates on Unicode code points and skips the surrogate
1276
+ * range, so the output is always a well-formed string: emoji and other
1277
+ * astral characters round-trip correctly (the previous code-unit based
1278
+ * version could emit lone surrogates and corrupt them).
1279
+ *
1280
+ * BREAKING CHANGE: values encoded with the previous code-unit algorithm
1281
+ * cannot be decoded by this implementation.
1282
+ *
1283
+ * @param text : the text to encode or decode
1284
+ * @param key : the key
1285
+ * @param reverse : true to decode, false to encode
1286
+ * @returns : the obfuscated (or restored) text, or undefined when text/key are empty
1485
1287
  */
1486
- transform(value, regexValue, replaceValue) {
1487
- if (!value)
1288
+ static cipher(text, key, reverse = false) {
1289
+ if (!text || !key)
1488
1290
  return undefined;
1489
- const replacement = (regexValue === '\n' && !replaceValue) ? '<br>' : replaceValue;
1490
- return this.sanitizer.bypassSecurityTrustHtml(value.replace(new RegExp(regexValue, 'g'), replacement));
1291
+ // Valid Unicode scalar values: [0, 0xD7FF] + [0xE000, 0x10FFFF].
1292
+ // We rotate inside this contiguous "logical" space (surrogates excluded)
1293
+ // so the result is always a valid code point.
1294
+ const SURROGATE_START = 0xd800;
1295
+ const SURROGATE_SIZE = 0x800;
1296
+ const SPACE = 0x110000 - SURROGATE_SIZE; // number of valid scalar values
1297
+ const toIndex = (cp) => (cp < SURROGATE_START ? cp : cp - SURROGATE_SIZE);
1298
+ const toCodePoint = (i) => (i < SURROGATE_START ? i : i + SURROGATE_SIZE);
1299
+ const keyLen = key.length;
1300
+ let result = '';
1301
+ let i = 0;
1302
+ for (const ch of text) { // iterates by code point, not code unit
1303
+ let rotation = key.charCodeAt(i % keyLen);
1304
+ if (reverse)
1305
+ rotation = -rotation;
1306
+ const index = toIndex(ch.codePointAt(0));
1307
+ const rotated = ((index + rotation) % SPACE + SPACE) % SPACE;
1308
+ result += String.fromCodePoint(toCodePoint(rotated));
1309
+ i++;
1310
+ }
1311
+ return result;
1491
1312
  }
1492
- static { this.ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "22.0.1", ngImport: i0, type: ReplacePipe, deps: [], target: i0.ɵɵFactoryTarget.Pipe }); }
1493
- static { this.ɵpipe = i0.ɵɵngDeclarePipe({ minVersion: "14.0.0", version: "22.0.1", ngImport: i0, type: ReplacePipe, isStandalone: true, name: "replace" }); }
1494
- }
1495
- i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "22.0.1", ngImport: i0, type: ReplacePipe, decorators: [{
1496
- type: Pipe,
1497
- args: [{
1498
- name: 'replace',
1499
- standalone: true
1500
- }]
1501
- }] });
1502
-
1503
- /**
1504
- * Pipe that marks an HTML string as trusted so Angular does not escape it when
1505
- * bound via `[innerHTML]`.
1506
- *
1507
- * Usage: `<div [innerHTML]="html | safeHtml"></div>`
1508
- */
1509
- class SafeHtmlPipe {
1510
- constructor() {
1511
- this.sanitizer = inject(DomSanitizer);
1313
+ // #region Real encryption (Web Crypto, AES-GCM)
1314
+ /** Payload format version, first byte of the binary envelope. */
1315
+ static { this.CIPHER_VERSION = 1; }
1316
+ /** PBKDF2 salt length in bytes. */
1317
+ static { this.CIPHER_SALT_LENGTH = 16; }
1318
+ /** AES-GCM IV length in bytes (96 bit, the recommended size for GCM). */
1319
+ static { this.CIPHER_IV_LENGTH = 12; }
1320
+ /** PBKDF2-HMAC-SHA-256 iterations (OWASP recommendation). */
1321
+ static { this.CIPHER_ITERATIONS = 600000; }
1322
+ /**
1323
+ * Derives an AES-GCM 256-bit key from a password using PBKDF2-HMAC-SHA-256.
1324
+ * @param password : the user password / passphrase
1325
+ * @param salt : the random salt bound to this payload
1326
+ * @returns : a non-extractable AES-GCM CryptoKey
1327
+ */
1328
+ static async deriveKey(password, salt) {
1329
+ const material = await crypto.subtle.importKey('raw', new TextEncoder().encode(password), 'PBKDF2', false, ['deriveKey']);
1330
+ return crypto.subtle.deriveKey({ name: 'PBKDF2', salt: salt, iterations: this.CIPHER_ITERATIONS, hash: 'SHA-256' }, material, { name: 'AES-GCM', length: 256 }, false, ['encrypt', 'decrypt']);
1331
+ }
1332
+ /**
1333
+ * Encodes bytes as URL-safe Base64 (no '+', '/' or trailing '=').
1334
+ * @param bytes : the bytes to encode
1335
+ * @returns : the base64url string
1336
+ */
1337
+ static toBase64Url(bytes) {
1338
+ let binary = '';
1339
+ const CHUNK = 0x8000;
1340
+ for (let i = 0; i < bytes.length; i += CHUNK) {
1341
+ binary += String.fromCharCode(...bytes.subarray(i, i + CHUNK));
1342
+ }
1343
+ return btoa(binary).replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/, '');
1344
+ }
1345
+ /**
1346
+ * Decodes a URL-safe Base64 string back to bytes.
1347
+ * @param value : the base64url string
1348
+ * @returns : the decoded bytes
1349
+ */
1350
+ static fromBase64Url(value) {
1351
+ const b64 = value.replace(/-/g, '+').replace(/_/g, '/');
1352
+ const padded = b64 + '='.repeat((4 - (b64.length % 4)) % 4);
1353
+ const binary = atob(padded);
1354
+ const bytes = new Uint8Array(binary.length);
1355
+ for (let i = 0; i < binary.length; i++)
1356
+ bytes[i] = binary.charCodeAt(i);
1357
+ return bytes;
1358
+ }
1359
+ /**
1360
+ * Encrypts a text with a password using AES-GCM (256 bit) and a key derived
1361
+ * via PBKDF2-HMAC-SHA-256 with a random per-message salt.
1362
+ *
1363
+ * The result is a self-contained, URL-safe Base64 payload:
1364
+ * `version (1 byte) | salt (16 bytes) | iv (12 bytes) | ciphertext+tag`.
1365
+ * GCM is authenticated: any tampering makes {@link decrypt} fail.
1366
+ *
1367
+ * Requires a secure context (HTTPS or localhost) for `crypto.subtle`.
1368
+ *
1369
+ * @param text : the plain text to encrypt
1370
+ * @param password : the password / passphrase
1371
+ * @returns : the base64url payload, or undefined when text/password are
1372
+ * empty or the Web Crypto API is unavailable
1373
+ */
1374
+ static async encrypt(text, password) {
1375
+ if (!text || !password)
1376
+ return undefined;
1377
+ if (typeof crypto === 'undefined' || !crypto.subtle)
1378
+ return undefined;
1379
+ try {
1380
+ const salt = crypto.getRandomValues(new Uint8Array(this.CIPHER_SALT_LENGTH));
1381
+ const iv = crypto.getRandomValues(new Uint8Array(this.CIPHER_IV_LENGTH));
1382
+ const key = await this.deriveKey(password, salt);
1383
+ const cipherText = new Uint8Array(await crypto.subtle.encrypt({ name: 'AES-GCM', iv: iv }, key, new TextEncoder().encode(text)));
1384
+ const payload = new Uint8Array(1 + salt.length + iv.length + cipherText.length);
1385
+ payload[0] = this.CIPHER_VERSION;
1386
+ payload.set(salt, 1);
1387
+ payload.set(iv, 1 + salt.length);
1388
+ payload.set(cipherText, 1 + salt.length + iv.length);
1389
+ return this.toBase64Url(payload);
1390
+ }
1391
+ catch {
1392
+ return undefined;
1393
+ }
1512
1394
  }
1513
1395
  /**
1514
- * Bypasses Angular's HTML sanitization and returns a `SafeHtml` instance.
1515
- * @param value - The raw HTML string to trust. Treated as an empty string when `undefined`.
1516
- * @returns A `SafeHtml` value that can be bound to `[innerHTML]` without escaping.
1396
+ * Decrypts a payload produced by {@link encrypt}.
1397
+ * Returns undefined when the password is wrong, the payload was tampered
1398
+ * with, the format is unknown, or the Web Crypto API is unavailable.
1399
+ *
1400
+ * @param payload : the base64url payload returned by {@link encrypt}
1401
+ * @param password : the password / passphrase used to encrypt
1402
+ * @returns : the original plain text, or undefined on any failure
1517
1403
  */
1518
- transform(value) {
1519
- return this.sanitizer.bypassSecurityTrustHtml(value ?? '');
1404
+ static async decrypt(payload, password) {
1405
+ if (!payload || !password)
1406
+ return undefined;
1407
+ if (typeof crypto === 'undefined' || !crypto.subtle)
1408
+ return undefined;
1409
+ try {
1410
+ const bytes = this.fromBase64Url(payload);
1411
+ const headerLength = 1 + this.CIPHER_SALT_LENGTH + this.CIPHER_IV_LENGTH;
1412
+ if (bytes.length <= headerLength || bytes[0] !== this.CIPHER_VERSION)
1413
+ return undefined;
1414
+ const salt = bytes.subarray(1, 1 + this.CIPHER_SALT_LENGTH);
1415
+ const iv = bytes.subarray(1 + this.CIPHER_SALT_LENGTH, headerLength);
1416
+ const cipherText = bytes.subarray(headerLength);
1417
+ const key = await this.deriveKey(password, salt);
1418
+ const plain = await crypto.subtle.decrypt({ name: 'AES-GCM', iv: iv }, key, cipherText);
1419
+ return new TextDecoder().decode(plain);
1420
+ }
1421
+ catch {
1422
+ // Wrong password or tampered payload: GCM authentication failed
1423
+ return undefined;
1424
+ }
1520
1425
  }
1521
- static { this.ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "22.0.1", ngImport: i0, type: SafeHtmlPipe, deps: [], target: i0.ɵɵFactoryTarget.Pipe }); }
1522
- static { this.ɵpipe = i0.ɵɵngDeclarePipe({ minVersion: "14.0.0", version: "22.0.1", ngImport: i0, type: SafeHtmlPipe, isStandalone: true, name: "safeHtml" }); }
1523
- }
1524
- i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "22.0.1", ngImport: i0, type: SafeHtmlPipe, decorators: [{
1525
- type: Pipe,
1526
- args: [{
1527
- name: 'safeHtml',
1528
- standalone: true
1529
- }]
1530
- }] });
1531
-
1532
- /**
1533
- * Pipe that marks a URL string as a trusted resource URL so Angular does not block it
1534
- * when bound to attributes such as `[src]` or `[href]` on iframes, objects, or embeds.
1535
- *
1536
- * Usage: `<iframe [src]="url | safeUrl"></iframe>`
1537
- */
1538
- class SafeUrlPipe {
1539
- constructor() {
1540
- this.sanitizer = inject(DomSanitizer);
1426
+ // #endregion
1427
+ /**
1428
+ * Clone an object (deep copy).
1429
+ * Uses native structuredClone: handles Date, Map, Set, typed arrays and circular references.
1430
+ * @param obj : the object to clone
1431
+ * @returns : a new object
1432
+ */
1433
+ static clone(obj) {
1434
+ if (obj === null || obj === undefined)
1435
+ return obj;
1436
+ return structuredClone(obj);
1541
1437
  }
1542
1438
  /**
1543
- * Bypasses Angular's resource-URL sanitization and returns a `SafeResourceUrl` instance.
1544
- * @param value - The URL string to trust. Treated as an empty string when `undefined`.
1545
- * @returns A `SafeResourceUrl` that can be bound to resource URL attributes without blocking.
1439
+ * Creates a deep clone of an object.
1440
+ * @param obj - The source object to clone.
1441
+ * @param dest - Optional pre-allocated destination object to merge the clone into.
1442
+ * @returns A deep copy of `obj`.
1546
1443
  */
1547
- transform(value) {
1548
- return this.sanitizer.bypassSecurityTrustResourceUrl(value ?? '');
1444
+ static deepClone(obj, dest) {
1445
+ const cloned = structuredClone(obj);
1446
+ if (dest) {
1447
+ Object.assign(dest, cloned);
1448
+ return dest;
1449
+ }
1450
+ return cloned;
1549
1451
  }
1550
- static { this.ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "22.0.1", ngImport: i0, type: SafeUrlPipe, deps: [], target: i0.ɵɵFactoryTarget.Pipe }); }
1551
- static { this.ɵpipe = i0.ɵɵngDeclarePipe({ minVersion: "14.0.0", version: "22.0.1", ngImport: i0, type: SafeUrlPipe, isStandalone: true, name: "safeUrl" }); }
1552
- }
1553
- i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "22.0.1", ngImport: i0, type: SafeUrlPipe, decorators: [{
1554
- type: Pipe,
1555
- args: [{
1556
- name: 'safeUrl',
1557
- standalone: true
1558
- }]
1559
- }] });
1560
-
1561
- /**
1562
- * Impure pipe that filters an array using a caller-provided predicate function.
1563
- * Because the pipe is impure it re-evaluates on every change-detection cycle,
1564
- * which is necessary when the predicate's captured state changes.
1565
- *
1566
- * Usage: `*ngFor="let item of items | callback:myFilter"`
1567
- */
1568
- class SearchCallbackPipe {
1569
1452
  /**
1570
- * Filters `items` by applying `callback` to each element.
1571
- * Returns the original array unchanged when either argument is falsy.
1572
- * @param items - The source array to filter. May be `undefined`.
1573
- * @param callback - A predicate function that returns `true` for items to keep.
1574
- * @returns A new filtered array, the original array when no callback is provided,
1575
- * or `undefined` when `items` is `undefined`.
1453
+ * Returns `true` when `value` is a syntactically valid UUID string.
1454
+ * @param value - The string to validate.
1576
1455
  */
1577
- transform(items, callback) {
1578
- if (!items || !callback)
1579
- return items;
1580
- return items.filter(item => callback(item));
1456
+ static parseUUID(value) {
1457
+ if (!value)
1458
+ return false;
1459
+ let s = value.trim();
1460
+ // Strip a matching wrapper pair, if present
1461
+ if ((s.startsWith('{') && s.endsWith('}')) || (s.startsWith('(') && s.endsWith(')'))) {
1462
+ s = s.slice(1, -1);
1463
+ }
1464
+ return /^[0-9a-fA-F]{8}-(?:[0-9a-fA-F]{4}-){3}[0-9a-fA-F]{12}$/.test(s);
1465
+ }
1466
+ /**
1467
+ * Returns `true` when `value` is a valid, non-empty (non-zero) UUID.
1468
+ * @param value - The string to validate.
1469
+ */
1470
+ static parseUUIDNotEmpty(value) {
1471
+ return this.parseUUID(value) && !/^[{(]?0{8}-(?:0{4}-){3}0{12}[)}]?$/.test((value ?? '').trim());
1581
1472
  }
1582
- static { this.ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "22.0.1", ngImport: i0, type: SearchCallbackPipe, deps: [], target: i0.ɵɵFactoryTarget.Pipe }); }
1583
- static { this.ɵpipe = i0.ɵɵngDeclarePipe({ minVersion: "14.0.0", version: "22.0.1", ngImport: i0, type: SearchCallbackPipe, isStandalone: true, name: "callback", pure: false }); }
1584
- }
1585
- i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "22.0.1", ngImport: i0, type: SearchCallbackPipe, decorators: [{
1586
- type: Pipe,
1587
- args: [{
1588
- name: 'callback',
1589
- pure: false,
1590
- standalone: true
1591
- }]
1592
- }] });
1593
-
1594
- /**
1595
- * Impure pipe that filters an array of searchable items against a text query.
1596
- *
1597
- * Each item is matched either via its `searchBag.name` property (when present)
1598
- * or by converting the item itself to a lowercase string. The optional `metadata`
1599
- * argument is updated in-place with the total item count and the filtered count,
1600
- * making it usable in the template alongside `*ngFor`.
1601
- *
1602
- * Usage:
1603
- * ```html
1604
- * <div *ngFor="let item of items | search:filterText:meta">...</div>
1605
- * <div>Showing {{ meta.count }} of {{ meta.total }}</div>
1606
- * ```
1607
- */
1608
- class SearchFilterPipe {
1609
1473
  /**
1610
- * Filters `items` by performing a case-insensitive substring match against `value`.
1611
- * When `items` or `value` is falsy the original array is returned unfiltered.
1612
- * @param items - The source array to filter. May be `undefined`.
1613
- * @param value - The search text to match against each item. May be `undefined`.
1614
- * @param metadata - Optional object that is updated with `total` and `count` after filtering.
1615
- * @returns The filtered array, the original array when no filter text is given,
1616
- * or `undefined` when `items` is `undefined`.
1474
+ * Return an empty UUID
1475
+ * @returns : the empty UUID
1617
1476
  */
1618
- transform(items, value, metadata) {
1619
- metadata ??= { total: 0, count: 0 };
1620
- if (!items || !value)
1621
- return items;
1622
- const query = value.toLowerCase();
1623
- const result = items.filter(item => {
1624
- if (!item)
1625
- return false;
1626
- const text = item.searchBag?.name ?? (typeof item === 'string' ? item : undefined);
1627
- return text?.toLowerCase().includes(query) ?? false;
1477
+ static emptyUUID() { return "00000000-0000-0000-0000-000000000000"; }
1478
+ ;
1479
+ /**
1480
+ * Create a new UUID
1481
+ * @returns : the string UUID
1482
+ */
1483
+ static generateUUID() {
1484
+ if (typeof crypto !== 'undefined' && typeof crypto.randomUUID === 'function') {
1485
+ return crypto.randomUUID();
1486
+ }
1487
+ // Fallback (non-secure contexts)
1488
+ return 'xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx'.replace(/[xy]/g, (c) => {
1489
+ const r = (Math.random() * 16) | 0;
1490
+ const v = c === 'x' ? r : (r & 0x3) | 0x8;
1491
+ return v.toString(16);
1628
1492
  });
1629
- metadata.total = items.length;
1630
- metadata.count = result.length;
1631
- return result;
1632
1493
  }
1633
- static { this.ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "22.0.1", ngImport: i0, type: SearchFilterPipe, deps: [], target: i0.ɵɵFactoryTarget.Pipe }); }
1634
- static { this.ɵpipe = i0.ɵɵngDeclarePipe({ minVersion: "14.0.0", version: "22.0.1", ngImport: i0, type: SearchFilterPipe, isStandalone: true, name: "search" }); }
1635
- }
1636
- i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "22.0.1", ngImport: i0, type: SearchFilterPipe, decorators: [{
1637
- type: Pipe,
1638
- args: [{
1639
- name: 'search',
1640
- standalone: true
1641
- }]
1642
- }] });
1643
-
1644
- /**
1645
- * Pipe that converts plain-text newlines (`\r\n`, `\r`, `\n`) to HTML `<br>` tags
1646
- * and marks the result as trusted HTML so Angular does not escape it.
1647
- *
1648
- * Usage: `{{ text | formatHtml }}`
1649
- */
1650
- class FormatHtmlPipe {
1651
- constructor() {
1652
- this.sanitizer = inject(DomSanitizer);
1494
+ /**
1495
+ * Reconstruct a standard UUID (with dashes) from a 32-char hex string without dashes.
1496
+ * @param value : 32-character hex string
1497
+ * @returns : the formatted UUID or the original string if it doesn't match the expected format
1498
+ */
1499
+ static inflateUUID(value) {
1500
+ const s = value.trim();
1501
+ if (s.length !== 32)
1502
+ return s;
1503
+ return `${s.slice(0, 8)}-${s.slice(8, 12)}-${s.slice(12, 16)}-${s.slice(16, 20)}-${s.slice(20)}`;
1653
1504
  }
1505
+ /** Precompiled validation patterns. */
1506
+ static { this.emailRegex = /^(?:[a-z0-9+!#$%&'*+\/=?^_`{|}~-]+(?:\.[a-z0-9!#$%&'*+\/=?^_`{|}~-]+)*|"(?:[\x01-\x08\x0b\x0c\x0e-\x1f\x21\x23-\x5b\x5d-\x7f]|\\[\x01-\x09\x0b\x0c\x0e-\x7f])*")@(?:(?:[a-z0-9](?:[a-z0-9-]*[a-z0-9])?\.)+[a-z0-9](?:[a-z0-9-]*[a-z0-9])?|\[(?:(?:25[0-5]|2[0-4][0-9]|[01]?[0-9][0-9]?)\.){3}(?:25[0-5]|2[0-4][0-9]|[01]?[0-9][0-9]?|[a-z0-9-]*[a-z0-9]:(?:[\x01-\x08\x0b\x0c\x0e-\x1f\x21-\x5a\x53-\x7f]|\\[\x01-\x09\x0b\x0c\x0e-\x7f])+)\])$/i; }
1507
+ static { this.urlRegex = /^(https?:\/\/)?([\da-z.-]+)\.([a-z.]{2,12})(:\d{1,5})?([/\w .%-]*)?(\?[^\s#]*)?(#\S*)?$/i; }
1654
1508
  /**
1655
- * Transforms a plain-text string into sanitized HTML by replacing newline characters
1656
- * with `<br>` tags.
1657
- * @param value - The input string to transform. Treated as an empty string when `undefined`.
1658
- * @returns A `SafeHtml` value that can be rendered with `[innerHTML]`.
1509
+ * Parse a text and return true if it is a valid email (or empty)
1510
+ * @param value : email
1511
+ * @returns : true if the email is valid or empty
1659
1512
  */
1660
- transform(value) {
1661
- return this.sanitizer.bypassSecurityTrustHtml((value ?? '').replaceAll(/(?:\r\n|\r|\n)/g, '<br>'));
1513
+ static parseEmail(value) {
1514
+ if (!value)
1515
+ return true;
1516
+ return this.emailRegex.test(value.trim().toLowerCase());
1662
1517
  }
1663
- static { this.ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "22.0.1", ngImport: i0, type: FormatHtmlPipe, deps: [], target: i0.ɵɵFactoryTarget.Pipe }); }
1664
- static { this.ɵpipe = i0.ɵɵngDeclarePipe({ minVersion: "14.0.0", version: "22.0.1", ngImport: i0, type: FormatHtmlPipe, isStandalone: true, name: "formatHtml" }); }
1665
- }
1666
- i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "22.0.1", ngImport: i0, type: FormatHtmlPipe, decorators: [{
1667
- type: Pipe,
1668
- args: [{
1669
- name: 'formatHtml',
1670
- standalone: true
1671
- }]
1672
- }] });
1673
-
1674
- /**
1675
- * Standalone providers for the ars-utils "core" layer.
1676
- *
1677
- * Registers the core pipes as injectable services so they can be used
1678
- * via `inject()` in services, guards, and resolvers — not only in templates.
1679
- * Components that use these pipes only in templates should import them
1680
- * directly via `imports: [FormatPipe, SafeHtmlPipe, ...]` instead.
1681
- *
1682
- * @example
1683
- * bootstrapApplication(AppComponent, {
1684
- * providers: [provideArsCore()]
1685
- * });
1686
- */
1687
- function provideArsCore() {
1688
- return makeEnvironmentProviders([
1689
- SearchFilterPipe,
1690
- SearchCallbackPipe,
1691
- SafeHtmlPipe,
1692
- SafeUrlPipe,
1693
- ReplacePipe,
1694
- FormatPipe,
1695
- FormatHtmlPipe
1696
- ]);
1697
- }
1698
-
1699
- /**
1700
- * Creates an array of the given length, filling each slot with the result of `valueFunction`.
1701
- * @param length - Number of elements to create.
1702
- * @param valueFunction - Factory called with each index to produce the element value.
1703
- * @returns Typed array of `length` elements.
1704
- */
1705
- function range(length, valueFunction) {
1706
- const valuesArray = Array(length);
1707
- for (let i = 0; i < length; i++) {
1708
- valuesArray[i] = valueFunction(i);
1518
+ /**
1519
+ * Parse a text containing one or more email addresses separated by `;` or `,`
1520
+ * and return true if all of them are valid (or the text is empty).
1521
+ * Empty items caused by double or trailing separators are ignored.
1522
+ * @param value : the email list to parse (e.g. "a@b.it; c@d.it")
1523
+ * @returns : true if all emails are valid or the value is empty
1524
+ */
1525
+ static parseEmails(value) {
1526
+ if (!value || value.trim().length === 0)
1527
+ return true;
1528
+ const items = value
1529
+ .split(/[;,]/)
1530
+ .map(e => e.trim().toLowerCase())
1531
+ .filter(e => e.length > 0);
1532
+ if (items.length === 0)
1533
+ return false; // only separators, no addresses
1534
+ return items.every(e => this.emailRegex.test(e));
1709
1535
  }
1710
- return valuesArray;
1711
- }
1712
- // date-fns doesn't have a way to read/print month names or days of the week directly,
1713
- // so we get them by formatting a date with a format that produces the desired month/day.
1714
- const MONTH_FORMATS = {
1715
- long: 'LLLL',
1716
- short: 'LLL',
1717
- narrow: 'LLLLL',
1718
- };
1719
- const DAY_OF_WEEK_FORMATS = {
1720
- long: 'EEEE',
1721
- short: 'EEE',
1722
- narrow: 'EEEEE',
1723
- };
1724
- const MAT_DATE_FNS_FORMATS = {
1725
- parse: {
1726
- dateInput: 'P',
1727
- },
1728
- display: {
1729
- dateInput: 'P',
1730
- monthYearLabel: 'LLL uuuu',
1731
- dateA11yLabel: 'PP',
1732
- monthYearA11yLabel: 'LLLL uuuu',
1733
- },
1734
- };
1735
- /**
1736
- * date-fns adapter that integrates Angular Material's date picker with the date-fns library,
1737
- * applying `Europe/Rome` timezone for all parsed and created dates.
1738
- */
1739
- class DateFnsAdapter extends DateAdapter {
1740
- constructor() {
1741
- super();
1742
- const matDateLocale = inject(MAT_DATE_LOCALE, { optional: true });
1743
- if (matDateLocale) {
1744
- this.setLocale(matDateLocale);
1536
+ /**
1537
+ * Parse a text and return true if it is a valid url (or empty)
1538
+ * @param value : the url to parse
1539
+ * @returns : true if the url is valid or empty
1540
+ */
1541
+ static parseUrl(value) {
1542
+ if (!value)
1543
+ return true;
1544
+ return this.urlRegex.test(value.trim().toLowerCase());
1545
+ }
1546
+ /**
1547
+ * Get date parts from a string value
1548
+ * @param value : the string to parse
1549
+ * @returns : an array of numbers with year, month, day
1550
+ */
1551
+ static getDateParts(value) {
1552
+ if (!value)
1553
+ return undefined;
1554
+ let parts = [];
1555
+ if (value.indexOf("-") !== -1) {
1556
+ const p = value.split("-");
1557
+ if (p.length !== 3)
1558
+ return undefined;
1559
+ parts = [parseInt(p[0]), parseInt(p[1]) - 1, parseInt(p[2])];
1560
+ }
1561
+ else if (value.indexOf("/") !== -1) {
1562
+ const p = value.split("/");
1563
+ if (p.length !== 3)
1564
+ return undefined;
1565
+ parts = [parseInt(p[2]), parseInt(p[1]) - 1, parseInt(p[0])];
1566
+ }
1567
+ else {
1568
+ return undefined;
1745
1569
  }
1570
+ return parts;
1746
1571
  }
1747
1572
  /**
1748
- * Returns the year component of the given date.
1749
- * @param date - The source date.
1573
+ * Checks whether a value is a valid Date instance.
1574
+ * Note: do not use `date.getTime()` as a truthiness test, it is 0 (falsy)
1575
+ * for the Unix epoch (1970-01-01T00:00:00Z) even though the date is valid.
1576
+ * @param d : the value to check
1577
+ * @returns : true when `d` is a Date representing a valid point in time
1750
1578
  */
1751
- getYear(date) {
1752
- return getYear(date);
1579
+ static isValidDate(d) {
1580
+ return d instanceof Date && !isNaN(d.getTime());
1753
1581
  }
1754
1582
  /**
1755
- * Returns the zero-based month index of the given date (0 = January).
1756
- * @param date - The source date.
1583
+ * Parse a date
1584
+ * @param value : the value to check
1585
+ * @param locale : the locale to use
1586
+ * @returns : a valid Date object or undefined
1757
1587
  */
1758
- getMonth(date) {
1759
- return getMonth(date);
1588
+ static parseDate(value, locale = it) {
1589
+ // No value at all
1590
+ if (!value)
1591
+ return undefined;
1592
+ // A Date value
1593
+ if (value instanceof Date)
1594
+ return this.isValidDate(value) ? value : undefined;
1595
+ // Parse known formats using date-fns
1596
+ let d = parseISO(value);
1597
+ if (this.isValidDate(d) && d.getFullYear() > 1750)
1598
+ return d;
1599
+ d = parse(value, 'dd/MM/yyyy', new Date(), { locale: locale });
1600
+ if (this.isValidDate(d) && d.getFullYear() > 1750)
1601
+ return d;
1602
+ d = parse(value, 'yyyy-MM-dd', new Date(), { locale: locale });
1603
+ if (this.isValidDate(d) && d.getFullYear() > 1750)
1604
+ return d;
1605
+ // Parse values manually
1606
+ const parts = this.getDateParts(value);
1607
+ if (!parts)
1608
+ return undefined;
1609
+ if (parts[0] < 100)
1610
+ parts[0] += 2000;
1611
+ if (isNaN(parts[0]) || parts[0] < 0 || parts[0] < 1750)
1612
+ return undefined;
1613
+ if (isNaN(parts[1]) || parts[1] < 0)
1614
+ return undefined;
1615
+ if (isNaN(parts[2]) || parts[2] < 0)
1616
+ return undefined;
1617
+ if (parts[1] > 11)
1618
+ return undefined;
1619
+ if (parts[1] === 1 && parts[2] > 29)
1620
+ return undefined;
1621
+ else if (parts[1] !== 1 && parts[2] > 31)
1622
+ return undefined;
1623
+ return new TZDate(parts[0], parts[1], parts[2], 12);
1760
1624
  }
1761
1625
  /**
1762
- * Returns the day-of-month of the given date (1-based).
1763
- * @param date - The source date.
1626
+ * Format a date
1627
+ * @param value : the date or string to format
1628
+ * @param fmt : the DateFormat to use or the string pattern
1629
+ * @param locale : the locale to use (default is IT)
1630
+ * @returns : the formatted string
1764
1631
  */
1765
- getDate(date) {
1766
- return getDate(date);
1632
+ static formatDate(value, fmt = DateFormat.Short, locale = it) {
1633
+ // No value at all
1634
+ if (!value)
1635
+ return '';
1636
+ // A string
1637
+ if (typeof value === 'string' || value instanceof String)
1638
+ value = this.parseDate(value);
1639
+ // Not a date
1640
+ if (!this.isValidDate(value))
1641
+ return '';
1642
+ // Format
1643
+ switch (fmt) {
1644
+ case DateFormat.Short: return format(value, "dd/MM/yyyy", { locale: locale });
1645
+ case DateFormat.Long: return format(value, "d MMMM yyyy", { locale: locale });
1646
+ case DateFormat.LongWithShortMonth: return format(value, "d MMM yyyy", { locale: locale });
1647
+ case DateFormat.LongWithWeekDay: return format(value, "EEEE, d MMMM yyyy", { locale: locale });
1648
+ case DateFormat.LongWithShortWeekDay: return format(value, "EEE, d MMMM yyyy", { locale: locale });
1649
+ case DateFormat.MonthAndYear: return format(value, "MMM yyyy", { locale: locale });
1650
+ case DateFormat.LongMonthAndYear: return format(value, "MMMM yyyy", { locale: locale });
1651
+ case DateFormat.WeekDay: return format(value, "EEE, d", { locale: locale });
1652
+ case DateFormat.LongWeekDay: return format(value, "EEEE, d", { locale: locale });
1653
+ case DateFormat.DayAndMonth: return format(value, "d MMMM", { locale: locale });
1654
+ case DateFormat.ShortUS: return format(value, "MM/dd/yyyy", { locale: locale });
1655
+ case DateFormat.ShortISO8601: return format(value, "yyyy-MM-dd", { locale: locale });
1656
+ default:
1657
+ return format(value, fmt, { locale: locale });
1658
+ }
1767
1659
  }
1768
1660
  /**
1769
- * Returns the day-of-week of the given date (0 = Sunday).
1770
- * @param date - The source date.
1661
+ * Return an italian local date
1662
+ * @param value : the date
1663
+ * @returns : the new date
1771
1664
  */
1772
- getDayOfWeek(date) {
1773
- return getDay(date);
1665
+ static toLocalDate(value) {
1666
+ // No value at all
1667
+ if (!value)
1668
+ return undefined;
1669
+ // A string
1670
+ if (typeof value === 'string' || value instanceof String)
1671
+ value = this.parseDate(value);
1672
+ // Not a date
1673
+ if (!this.isValidDate(value))
1674
+ return undefined;
1675
+ // Update date
1676
+ return new TZDate(value, "Europe/Rome");
1774
1677
  }
1775
1678
  /**
1776
- * Returns an array of 12 month name strings formatted for the active locale.
1777
- * @param style - One of `'long'`, `'short'`, or `'narrow'`.
1679
+ * Update a DateInterval object according to a string
1680
+ * @param value : string value
1681
+ * @param interval : DateInterval value to update
1682
+ * @param end : true if must be updated the first or the end value
1683
+ * @param copy : copy the same value (works only if not end element)
1778
1684
  */
1779
- getMonthNames(style) {
1780
- const pattern = MONTH_FORMATS[style];
1781
- return range(12, i => this.format(new Date(2017, i, 1), pattern));
1685
+ static changeDateInterval(value, interval, end = false, copy = false) {
1686
+ if (value) {
1687
+ let year = -1;
1688
+ if (value.length === 4 && (year = parseInt(value)) > 1750) {
1689
+ if (!end) {
1690
+ interval.from = new TZDate(new Date(year, 0, 1), 'Europe/Rome');
1691
+ interval.to = new TZDate(new Date(year, 11, 31), 'Europe/Rome');
1692
+ }
1693
+ else
1694
+ interval.to = new Date(year, 11, 31);
1695
+ }
1696
+ else {
1697
+ const parts = this.getDateParts(value);
1698
+ if (!parts || parts.length !== 3)
1699
+ return;
1700
+ if (isNaN(parts[0]) || parts[0] < 1750)
1701
+ return;
1702
+ if (isNaN(parts[1]) || parts[1] < 0 || parts[1] > 11)
1703
+ return;
1704
+ if (isNaN(parts[2]) || parts[2] < 1 || parts[2] > 31)
1705
+ return;
1706
+ const d = new TZDate(new Date(parts[0], parts[1], parts[2]), 'Europe/Rome');
1707
+ if (end)
1708
+ interval.to = d;
1709
+ else if (copy) {
1710
+ interval.from = d;
1711
+ interval.to = d;
1712
+ }
1713
+ else
1714
+ interval.from = d;
1715
+ }
1716
+ }
1782
1717
  }
1718
+ /** Cache of `Intl.NumberFormat` instances (their construction is expensive). */
1719
+ static { this.numberFormatCache = new Map(); }
1783
1720
  /**
1784
- * Returns an array of 31 day-of-month label strings formatted using `Intl.DateTimeFormat`
1785
- * when available, falling back to plain numeric strings.
1721
+ * Returns a cached `Intl.NumberFormat` for the given options, creating it on first use.
1722
+ * @param locale - BCP 47 locale tag.
1723
+ * @param options - The `Intl.NumberFormat` options.
1724
+ * @returns A shared formatter instance.
1786
1725
  */
1787
- getDateNames() {
1788
- const dtf = typeof Intl !== 'undefined'
1789
- ? new Intl.DateTimeFormat(this.locale?.code, {
1790
- day: 'numeric',
1791
- timeZone: 'utc',
1792
- })
1793
- : null;
1794
- return range(31, i => {
1795
- if (dtf) {
1796
- // date-fns doesn't appear to support this functionality.
1797
- // Fall back to `Intl` on supported browsers.
1798
- const date = new Date();
1799
- date.setUTCFullYear(2017, 0, i + 1);
1800
- date.setUTCHours(0, 0, 0, 0);
1801
- return dtf.format(date).replace(/[\u200e\u200f]/g, '');
1802
- }
1803
- return String(i + 1);
1804
- });
1726
+ static getNumberFormat(locale, options) {
1727
+ const key = `${locale}|${options.style}|${options.currency ?? ''}|${options.minimumFractionDigits}|${options.maximumFractionDigits}`;
1728
+ let fmt = this.numberFormatCache.get(key);
1729
+ if (!fmt) {
1730
+ fmt = new Intl.NumberFormat(locale, options);
1731
+ this.numberFormatCache.set(key, fmt);
1732
+ }
1733
+ return fmt;
1805
1734
  }
1806
1735
  /**
1807
- * Returns an array of 7 day-of-week name strings formatted for the active locale.
1808
- * @param style - One of `'long'`, `'short'`, or `'narrow'`.
1736
+ * Formats a number using a cached `Intl.NumberFormat`.
1737
+ * @param value - The number to format.
1738
+ * @param decimals - Maximum decimal places (default: `2`).
1739
+ * @param locale - BCP 47 locale tag (default: `'it-IT'`).
1740
+ * @returns The formatted number string.
1809
1741
  */
1810
- getDayOfWeekNames(style) {
1811
- const pattern = DAY_OF_WEEK_FORMATS[style];
1812
- return range(7, i => this.format(new Date(2017, 0, i + 1), pattern));
1742
+ static formatNumber(value, decimals = 2, locale = 'it-IT') {
1743
+ return this.getNumberFormat(locale, { style: 'decimal', minimumFractionDigits: 0, maximumFractionDigits: decimals }).format(value);
1813
1744
  }
1814
1745
  /**
1815
- * Returns the four-digit year string for the given date.
1816
- * @param date - The source date.
1746
+ * Formats a number as a currency string using a cached `Intl.NumberFormat`.
1747
+ * @param value - The number to format.
1748
+ * @param currency - ISO 4217 currency code (default: `'EUR'`).
1749
+ * @param decimals - Maximum decimal places (default: `2`).
1750
+ * @param locale - BCP 47 locale tag (default: `'it-IT'`).
1751
+ * @returns The formatted currency string.
1817
1752
  */
1818
- getYearName(date) {
1819
- return this.format(date, 'y');
1753
+ static formatCurrency(value, currency = 'EUR', decimals = 2, locale = 'it-IT') {
1754
+ return this.getNumberFormat(locale, { style: 'currency', currency, minimumFractionDigits: 0, maximumFractionDigits: decimals }).format(value);
1820
1755
  }
1821
1756
  /**
1822
- * Returns the first day of the week for the active locale (0 = Sunday, 1 = Monday, …).
1757
+ * Percent-encodes a string for safe inclusion in a URL.
1758
+ * @param value - The string to encode.
1759
+ * @returns The encoded string, or `undefined` when `value` is empty.
1823
1760
  */
1824
- getFirstDayOfWeek() {
1825
- return this.locale?.options?.weekStartsOn ?? 0;
1761
+ static urlEncode(value) {
1762
+ return value.length > 0
1763
+ ? encodeURIComponent(value)
1764
+ : undefined;
1826
1765
  }
1827
1766
  /**
1828
- * Returns the number of days in the month of the given date.
1829
- * @param date - The source date.
1767
+ * Decodes a percent-encoded URL string, treating `+` as a space.
1768
+ * @param value - The encoded string to decode.
1769
+ * @returns The decoded string, or `undefined` when `value` is empty or absent.
1830
1770
  */
1831
- getNumDaysInMonth(date) {
1832
- return getDaysInMonth(date);
1771
+ static urlDecode(value) {
1772
+ return value && value.length > 0
1773
+ ? decodeURIComponent(value.replace(/\+/g, '%20'))
1774
+ : undefined;
1833
1775
  }
1834
1776
  /**
1835
- * Creates an independent copy of the given date.
1836
- * @param date - The date to clone.
1777
+ * Reads a query string parameter value from the current page URL.
1778
+ * @param name - The parameter name to look up.
1779
+ * @returns The decoded parameter value, or `undefined` when absent or running server-side.
1837
1780
  */
1838
- clone(date) {
1839
- return new Date(date.getTime());
1781
+ static getQueryStringValueByName(name) {
1782
+ if (!this.isBrowser())
1783
+ return undefined;
1784
+ const v = new URLSearchParams(window.location.search).get(name);
1785
+ return v && v !== 'null' ? v : undefined;
1840
1786
  }
1841
1787
  /**
1842
- * Creates a `Date` in the `Europe/Rome` timezone for the given year, month, and day.
1843
- * Throws an `Error` when any component is out of range.
1844
- * @param year - Full four-digit year.
1845
- * @param month - Zero-based month index (0 = January, 11 = December).
1846
- * @param date - Day-of-month (1-based).
1788
+ * Generate a password
1789
+ * @returns : the password string
1847
1790
  */
1848
- createDate(year, month, date) {
1849
- if (month < 0 || month > 11) {
1850
- throw Error(`Invalid month index "${month}". Month index has to be between 0 and 11.`);
1851
- }
1852
- if (date < 1) {
1853
- throw Error(`Invalid date "${date}". Date has to be greater than 0.`);
1791
+ static generatePassword(length = 12) {
1792
+ const lower = 'abcdefghijkmnopqrstuvwxyz'; // no 'l' (ambiguous)
1793
+ const upper = 'ABCDEFGHJKLMNPQRSTUVWXYZ'; // no 'I'/'O' (ambiguous)
1794
+ const digits = '23456789'; // no '0'/'1' (ambiguous)
1795
+ const symbols = '$!#%&*+-';
1796
+ const all = lower + upper + digits + symbols;
1797
+ // Secure random indices (crypto is available in browsers and Node)
1798
+ const rnd = (max) => {
1799
+ if (typeof crypto !== 'undefined' && typeof crypto.getRandomValues === 'function') {
1800
+ const buf = new Uint32Array(1);
1801
+ crypto.getRandomValues(buf);
1802
+ return buf[0] % max;
1803
+ }
1804
+ return Math.floor(Math.random() * max);
1805
+ };
1806
+ // Guarantee at least one char per class, fill the rest, then shuffle
1807
+ const chars = [
1808
+ lower[rnd(lower.length)],
1809
+ upper[rnd(upper.length)],
1810
+ digits[rnd(digits.length)],
1811
+ symbols[rnd(symbols.length)],
1812
+ ];
1813
+ while (chars.length < Math.max(length, 8)) {
1814
+ chars.push(all[rnd(all.length)]);
1854
1815
  }
1855
- // Passing the year to the constructor causes year numbers <100 to be converted to 19xx.
1856
- // To work around this we use `setFullYear` and `setHours` instead.
1857
- const result = new Date();
1858
- result.setFullYear(year, month, date);
1859
- result.setHours(0, 0, 0, 0);
1860
- const result2 = new TZDate(result, 'Europe/Rome');
1861
- if (result2.getMonth() !== month) {
1862
- throw Error(`Invalid date "${date}" for month with index "${month}".`);
1816
+ for (let i = chars.length - 1; i > 0; i--) {
1817
+ const j = rnd(i + 1);
1818
+ [chars[i], chars[j]] = [chars[j], chars[i]];
1863
1819
  }
1864
- return result2;
1865
- }
1866
- /**
1867
- * Returns today's date in the local timezone.
1868
- */
1869
- today() {
1870
- return new Date();
1820
+ return chars.join('');
1871
1821
  }
1872
1822
  /**
1873
- * Parses a value into a `Date`.
1874
- * - Strings are first attempted as ISO 8601, then matched against each format in `parseFormat`.
1875
- * - Numbers are treated as Unix timestamps (milliseconds).
1876
- * - Existing `Date` instances are cloned.
1877
- * @param value - The value to parse.
1878
- * @param parseFormat - A format string or an array of format strings (date-fns tokens).
1879
- * @returns A valid `Date` in `Europe/Rome`, an invalid sentinel, or `null` for unrecognised input.
1823
+ * Calculate password strength
1824
+ * @param password: the password to evaluate
1825
+ * @returns the password strength info
1880
1826
  */
1881
- parse(value, parseFormat) {
1882
- if (typeof value === 'string' && value.length > 0) {
1883
- const iso8601Date = parseISO(value);
1884
- if (this.isValid(iso8601Date)) {
1885
- return new TZDate(iso8601Date, 'Europe/Rome');
1827
+ static calculatePasswordStrength(password) {
1828
+ if (password && password.length > 0) {
1829
+ let score = 0;
1830
+ const suggestions = [];
1831
+ // Length
1832
+ if (password.length >= 10)
1833
+ score++;
1834
+ else
1835
+ suggestions.push('Usa almeno 10 caratteri.');
1836
+ if (password.length >= 12)
1837
+ score++;
1838
+ else if (password.length >= 10)
1839
+ suggestions.push('Considera di usare più di 12 caratteri.');
1840
+ // Lowercase letters
1841
+ if (/[a-z]/.test(password))
1842
+ score++;
1843
+ else
1844
+ suggestions.push('Aggiungi lettere minuscole.');
1845
+ // Uppercase letters
1846
+ if (/[A-Z]/.test(password))
1847
+ score++;
1848
+ else
1849
+ suggestions.push('Aggiungi lettere maiuscole.');
1850
+ // Numbers
1851
+ if (/\d/.test(password))
1852
+ score++;
1853
+ else
1854
+ suggestions.push('Aggiungi numeri.');
1855
+ // Special characters
1856
+ if (/[!@#$%^&*()_+\-=\[\]{};':"\\|,.<>\/?]/.test(password))
1857
+ score++;
1858
+ else
1859
+ suggestions.push('Aggiungi caratteri speciali (!@#$%^&*).');
1860
+ // Common patterns
1861
+ if (/(.)\1{2,}/.test(password)) {
1862
+ score = Math.max(0, score - 1);
1863
+ suggestions.push('Evita di ripetere lo stesso carattere.');
1886
1864
  }
1887
- const formats = Array.isArray(parseFormat) ? parseFormat : [parseFormat];
1888
- if (!formats.length) {
1889
- throw Error('Formats array must not be empty.');
1865
+ if (/123|abc|qwe/i.test(password)) {
1866
+ score = Math.max(0, score - 1);
1867
+ suggestions.push('Evita sequenze comuni (123, abc, qwe).');
1868
+ }
1869
+ // Label and color
1870
+ let label;
1871
+ let color;
1872
+ let isValid;
1873
+ if (score <= 2) {
1874
+ label = 'Molto debole';
1875
+ color = '#f44336';
1876
+ isValid = false;
1877
+ }
1878
+ else if (score <= 3) {
1879
+ label = 'Debole';
1880
+ color = '#ff9800';
1881
+ isValid = false;
1882
+ }
1883
+ else if (score <= 4) {
1884
+ label = 'Media';
1885
+ color = '#ffc107';
1886
+ isValid = true;
1890
1887
  }
1891
- for (const currentFormat of formats) {
1892
- const fromFormat = parse(value, currentFormat, new Date(), { locale: this.locale });
1893
- if (this.isValid(fromFormat)) {
1894
- return new TZDate(fromFormat, 'Europe/Rome');
1895
- }
1888
+ else if (score <= 5) {
1889
+ label = 'Forte';
1890
+ color = '#8bc34a';
1891
+ isValid = true;
1896
1892
  }
1897
- return this.invalid();
1898
- }
1899
- else if (typeof value === 'number') {
1900
- return new Date(value);
1901
- }
1902
- else if (value instanceof Date) {
1903
- return this.clone(value);
1904
- }
1905
- return null;
1906
- }
1907
- /**
1908
- * Formats a `Date` using the given date-fns display format string.
1909
- * Throws an `Error` when `date` is not valid.
1910
- * @param date - The date to format.
1911
- * @param displayFormat - A date-fns format string (e.g. `'P'`, `'LLL uuuu'`).
1912
- */
1913
- format(date, displayFormat) {
1914
- if (!this.isValid(date)) {
1915
- throw Error('DateFnsAdapter: Cannot format invalid date.');
1893
+ else {
1894
+ label = 'Molto forte';
1895
+ color = '#4caf50';
1896
+ isValid = true;
1897
+ }
1898
+ return { score, label, color, suggestions, isValid };
1916
1899
  }
1917
- return format(date, displayFormat, { locale: this.locale });
1900
+ else
1901
+ return {
1902
+ score: 0,
1903
+ isValid: false,
1904
+ suggestions: []
1905
+ };
1918
1906
  }
1919
1907
  /**
1920
- * Adds the given number of whole years to a date.
1921
- * @param date - The base date.
1922
- * @param years - Number of years to add (can be negative).
1908
+ * Check if current browser supports touch
1909
+ * @returns : true if the display is touchable
1923
1910
  */
1924
- addCalendarYears(date, years) {
1925
- return addYears(date, years);
1911
+ static isTouchable() {
1912
+ return (this.isBrowser() && ("ontouchstart" in window ||
1913
+ navigator.maxTouchPoints > 0));
1926
1914
  }
1927
1915
  /**
1928
- * Adds the given number of whole months to a date.
1929
- * @param date - The base date.
1930
- * @param months - Number of months to add (can be negative).
1916
+ * This check will prevent 'window' logic to be executed
1917
+ * while executing the server rendering
1918
+ * @returns : true if using the browser
1931
1919
  */
1932
- addCalendarMonths(date, months) {
1933
- return addMonths(date, months);
1920
+ static isBrowser() {
1921
+ return typeof (window) !== 'undefined';
1934
1922
  }
1935
1923
  /**
1936
- * Adds the given number of whole days to a date.
1937
- * @param date - The base date.
1938
- * @param days - Number of days to add (can be negative).
1924
+ * Convert folders in a tree of Node object.
1925
+ * @param folders : the subfolders group or null to root
1926
+ * @returns : a node list
1939
1927
  */
1940
- addCalendarDays(date, days) {
1941
- return addDays(date, days);
1928
+ static toNodes(folders) {
1929
+ return this._toNodes(folders, undefined);
1942
1930
  }
1943
1931
  /**
1944
- * Serialises a date to an ISO 8601 date string (`yyyy-MM-dd`).
1945
- * @param date - The date to serialise.
1932
+ * Convert folders in a tree of Node object.
1933
+ * @param folders : the children group or null to root
1934
+ * @param parent : the parent node
1935
+ * @returns : a node list
1946
1936
  */
1947
- toIso8601(date) {
1948
- return formatISO(date, { representation: 'date' });
1937
+ static _toNodes(folders, parent) {
1938
+ const nodes = [];
1939
+ folders.forEach((n) => {
1940
+ const node = {
1941
+ id: n.id,
1942
+ name: n.name,
1943
+ count: n.count,
1944
+ parent: parent,
1945
+ children: undefined,
1946
+ bag: n,
1947
+ };
1948
+ const children = n.children ?? n.subFolders ?? [];
1949
+ if (children.length > 0) {
1950
+ node.children = this._toNodes(children, node);
1951
+ }
1952
+ nodes.push(node);
1953
+ });
1954
+ return nodes;
1949
1955
  }
1950
1956
  /**
1951
- * Returns the given value when it is a valid `Date`, or `null` for an empty string.
1952
- * Deserialises valid ISO 8601 strings into `Date` instances.
1953
- * Delegates all other values to the base-class implementation.
1954
- * @param value - The raw value to deserialise.
1957
+ * Returns an array of individual power-of-2 flag values that are set in `value`.
1958
+ * @param value - The bitmask to decompose.
1959
+ * @param max - Upper-bound exponent: checks flags from `1` up to `1 << max` (default: `30`).
1960
+ * @returns Array of set flag values, or an empty array when `value` is `0`.
1955
1961
  */
1956
- deserialize(value) {
1957
- if (typeof value === 'string') {
1958
- if (!value) {
1959
- return null;
1960
- }
1961
- const date = parseISO(value);
1962
- if (this.isValid(date)) {
1963
- return date;
1962
+ static getFlags(value, max = 30) {
1963
+ if (value !== 0) {
1964
+ const items = [];
1965
+ const top = Math.min(max, 30); // beyond 30 the shift overflows into the sign bit
1966
+ for (let exp = 0; exp <= top; exp++) {
1967
+ const v = 1 << exp;
1968
+ if ((value & v) === v)
1969
+ items.push(v);
1964
1970
  }
1971
+ return items;
1965
1972
  }
1966
- return super.deserialize(value);
1967
- }
1968
- /**
1969
- * Returns `true` when `obj` is an instance of `Date`.
1970
- * @param obj - The object to test.
1971
- */
1972
- isDateInstance(obj) {
1973
- return isDate(obj);
1974
- }
1975
- /**
1976
- * Returns `true` when `date` represents a valid point in time.
1977
- * @param date - The date to validate.
1978
- */
1979
- isValid(date) {
1980
- return isValid(date);
1973
+ else
1974
+ return [];
1981
1975
  }
1976
+ /** Cache for resolved color luminance results. */
1977
+ static { this.colorLightCache = new Map(); }
1982
1978
  /**
1983
- * Returns a sentinel `Date` that represents an invalid date (`new Date(NaN)`).
1979
+ * Check if a color is light or dark
1980
+ * @param color : the color
1981
+ * @param minimumLuminance : the lumimance to consider
1982
+ * @returns true if the color is light
1984
1983
  */
1985
- invalid() {
1986
- return new Date(NaN);
1987
- }
1988
- static { this.ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "22.0.1", ngImport: i0, type: DateFnsAdapter, deps: [], target: i0.ɵɵFactoryTarget.Injectable }); }
1989
- static { this.ɵprov = i0.ɵɵngDeclareInjectable({ minVersion: "12.0.0", version: "22.0.1", ngImport: i0, type: DateFnsAdapter }); }
1990
- }
1991
- i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "22.0.1", ngImport: i0, type: DateFnsAdapter, decorators: [{
1992
- type: Injectable
1993
- }], ctorParameters: () => [] });
1994
- /**
1995
- * Standalone providers for the ars-utils date-fns adapter.
1996
- *
1997
- * Configures Angular Material to use {@link DateFnsAdapter} (Europe/Rome timezone)
1998
- * and the matching {@link MAT_DATE_FNS_FORMATS}.
1999
- *
2000
- * @example
2001
- * bootstrapApplication(AppComponent, {
2002
- * providers: [provideArsDateFns()]
2003
- * });
2004
- */
2005
- function provideArsDateFns() {
2006
- return makeEnvironmentProviders([
2007
- {
2008
- provide: DateAdapter,
2009
- useClass: DateFnsAdapter,
2010
- deps: [MAT_DATE_LOCALE],
2011
- },
2012
- { provide: MAT_DATE_FORMATS, useValue: MAT_DATE_FNS_FORMATS }
2013
- ]);
2014
- }
2015
-
2016
- /**
2017
- * Directive that moves browser focus to the host element after the first render cycle.
2018
- * Apply `autoFocus` to any focusable element to set focus automatically on initialisation.
2019
- */
2020
- class AutoFocusDirective {
2021
- constructor() {
2022
- this.elementRef = inject(ElementRef);
2023
- afterNextRender(() => {
2024
- this.elementRef.nativeElement?.focus();
2025
- });
2026
- }
2027
- static { this.ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "22.0.1", ngImport: i0, type: AutoFocusDirective, deps: [], target: i0.ɵɵFactoryTarget.Directive }); }
2028
- static { this.ɵdir = i0.ɵɵngDeclareDirective({ minVersion: "14.0.0", version: "22.0.1", type: AutoFocusDirective, isStandalone: true, selector: "[autoFocus]", ngImport: i0 }); }
2029
- }
2030
- i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "22.0.1", ngImport: i0, type: AutoFocusDirective, decorators: [{
2031
- type: Directive,
2032
- args: [{
2033
- selector: '[autoFocus]',
2034
- standalone: true
2035
- }]
2036
- }], ctorParameters: () => [] });
2037
-
2038
- class FileInfo {
2039
- isValid() {
2040
- return this.valid;
2041
- }
2042
- }
2043
- class ValueModel {
2044
- }
2045
- class IDModel {
2046
- }
2047
- class GroupModel {
2048
- }
2049
- class DeleteModel extends GroupModel {
2050
- }
2051
- class RelationModel {
2052
- }
2053
- class UpdateRelationsModel {
2054
- }
2055
- class QueryModel {
2056
- }
2057
- class ImportModel {
2058
- }
2059
- class DateInterval {
2060
- get fromAsDate() {
2061
- if (this.from) {
2062
- if (!(this.from instanceof Date)) {
2063
- this.from = new Date(this.from);
2064
- }
2065
- if (this.from) {
2066
- return new Date(this.from.getFullYear(), this.from.getMonth(), this.from.getDate(), 2, 0, 0);
2067
- }
2068
- }
2069
- return undefined;
2070
- }
2071
- get toAsDate() {
2072
- if (this.to) {
2073
- if (!(this.to instanceof Date)) {
2074
- this.to = new Date(this.to);
2075
- }
2076
- if (this.to) {
2077
- return new Date(this.to.getFullYear(), this.to.getMonth(), this.to.getDate(), 2, 0, 0);
2078
- }
2079
- }
2080
- return undefined;
2081
- }
2082
- constructor(from, to) {
2083
- this.from = from;
2084
- this.to = to;
2085
- }
2086
- clear() {
2087
- this.from = undefined;
2088
- this.to = undefined;
1984
+ static isColorLight(color, minimumLuminance = 186) {
1985
+ if (!this.isBrowser())
1986
+ return true; // SSR fallback
1987
+ const cacheKey = `${color}|${minimumLuminance}`;
1988
+ const cached = this.colorLightCache.get(cacheKey);
1989
+ if (cached !== undefined)
1990
+ return cached;
1991
+ const tempDiv = document.createElement('div');
1992
+ tempDiv.style.color = color;
1993
+ document.body.appendChild(tempDiv);
1994
+ const rgb = getComputedStyle(tempDiv).color;
1995
+ document.body.removeChild(tempDiv);
1996
+ const result = rgb.match(/\d+/g);
1997
+ let light = true; // fallback
1998
+ if (result && result.length >= 3) {
1999
+ const r = parseInt(result[0], 10);
2000
+ const g = parseInt(result[1], 10);
2001
+ const b = parseInt(result[2], 10);
2002
+ light = (0.299 * r + 0.587 * g + 0.114 * b) > minimumLuminance;
2003
+ }
2004
+ this.colorLightCache.set(cacheKey, light);
2005
+ return light;
2089
2006
  }
2090
2007
  }
2091
2008
 
@@ -2097,7 +2014,7 @@ class DateInterval {
2097
2014
  class DateIntervalChangeDirective {
2098
2015
  constructor() {
2099
2016
  /** The date interval model to update when the input value changes. */
2100
- this.dateIntervalChange = input(new DateInterval(null, null), /* @ts-ignore */
2017
+ this.dateIntervalChange = input(new DateInterval(), /* @ts-ignore */
2101
2018
  ...(ngDevMode ? [{ debugName: "dateIntervalChange" }] : /* istanbul ignore next */ []));
2102
2019
  /** When `true`, the directive updates the interval's end date; otherwise the start date. */
2103
2020
  this.end = input(false, /* @ts-ignore */
@@ -2114,19 +2031,27 @@ class DateIntervalChangeDirective {
2114
2031
  });
2115
2032
  }
2116
2033
  /**
2117
- * Handles `keyup` events on the host element.
2118
- * Prevents default browser behaviour for the space key and forwards the event to the debounce pipeline.
2034
+ * Handles `keydown` events on the host element.
2035
+ * The space key must be blocked HERE: by the time `keyup` fires the character
2036
+ * has already been inserted into the input, so `preventDefault` on `keyup`
2037
+ * cannot stop it.
2119
2038
  * @param e - The keyboard event emitted by the host input.
2120
2039
  */
2121
- onKeyup(e) {
2040
+ onKeydown(e) {
2122
2041
  if (e.key === ' ') {
2123
2042
  e.preventDefault();
2124
2043
  e.stopPropagation();
2125
2044
  }
2045
+ }
2046
+ /**
2047
+ * Handles `keyup` events on the host element and forwards them to the debounce pipeline.
2048
+ * @param e - The keyboard event emitted by the host input.
2049
+ */
2050
+ onKeyup(e) {
2126
2051
  this.subject.next(e);
2127
2052
  }
2128
2053
  static { this.ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "22.0.1", ngImport: i0, type: DateIntervalChangeDirective, deps: [], target: i0.ɵɵFactoryTarget.Directive }); }
2129
- static { this.ɵdir = i0.ɵɵngDeclareDirective({ minVersion: "17.1.0", version: "22.0.1", type: DateIntervalChangeDirective, isStandalone: true, selector: "[dateIntervalChange]", inputs: { dateIntervalChange: { classPropertyName: "dateIntervalChange", publicName: "dateIntervalChange", isSignal: true, isRequired: false, transformFunction: null }, end: { classPropertyName: "end", publicName: "end", isSignal: true, isRequired: false, transformFunction: null } }, host: { listeners: { "keyup": "onKeyup($event)" } }, ngImport: i0 }); }
2054
+ static { this.ɵdir = i0.ɵɵngDeclareDirective({ minVersion: "17.1.0", version: "22.0.1", type: DateIntervalChangeDirective, isStandalone: true, selector: "[dateIntervalChange]", inputs: { dateIntervalChange: { classPropertyName: "dateIntervalChange", publicName: "dateIntervalChange", isSignal: true, isRequired: false, transformFunction: null }, end: { classPropertyName: "end", publicName: "end", isSignal: true, isRequired: false, transformFunction: null } }, host: { listeners: { "keydown": "onKeydown($event)", "keyup": "onKeyup($event)" } }, ngImport: i0 }); }
2130
2055
  }
2131
2056
  i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "22.0.1", ngImport: i0, type: DateIntervalChangeDirective, decorators: [{
2132
2057
  type: Directive,
@@ -2134,7 +2059,10 @@ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "22.0.1", ngImpor
2134
2059
  selector: '[dateIntervalChange]',
2135
2060
  standalone: true,
2136
2061
  }]
2137
- }], ctorParameters: () => [], propDecorators: { dateIntervalChange: [{ type: i0.Input, args: [{ isSignal: true, alias: "dateIntervalChange", required: false }] }], end: [{ type: i0.Input, args: [{ isSignal: true, alias: "end", required: false }] }], onKeyup: [{
2062
+ }], ctorParameters: () => [], propDecorators: { dateIntervalChange: [{ type: i0.Input, args: [{ isSignal: true, alias: "dateIntervalChange", required: false }] }], end: [{ type: i0.Input, args: [{ isSignal: true, alias: "end", required: false }] }], onKeydown: [{
2063
+ type: HostListener,
2064
+ args: ['keydown', ['$event']]
2065
+ }], onKeyup: [{
2138
2066
  type: HostListener,
2139
2067
  args: ['keyup', ['$event']]
2140
2068
  }] } });
@@ -2163,6 +2091,7 @@ class CopyClipboardDirective {
2163
2091
  if (SystemUtils.isBrowser()) {
2164
2092
  const listener = (clipEvent) => {
2165
2093
  clipEvent.clipboardData?.setData('text/html', payload);
2094
+ clipEvent.clipboardData?.setData('text/plain', payload);
2166
2095
  clipEvent.preventDefault();
2167
2096
  this.copied.emit(payload);
2168
2097
  };
@@ -2230,17 +2159,37 @@ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "22.0.1", ngImpor
2230
2159
  /**
2231
2160
  * Directive that validates that the host control's value equals the value of another control.
2232
2161
  * Bind `[equals]="otherControl"`.
2162
+ *
2163
+ * The host control is re-validated whenever the OTHER control's value changes:
2164
+ * without this, typing a new password AFTER the confirmation field was filled
2165
+ * left the form incorrectly valid (the classic password/confirm bug).
2233
2166
  */
2234
2167
  class EqualsValidatorDirective {
2235
2168
  constructor() {
2236
2169
  /** The control whose value must match the host control's value. */
2237
2170
  this.equals = input(undefined, /* @ts-ignore */
2238
2171
  ...(ngDevMode ? [{ debugName: "equals" }] : /* istanbul ignore next */ []));
2172
+ // Re-subscribe whenever the [equals] binding points to a different control,
2173
+ // and trigger host re-validation on every change of the other control.
2174
+ effect(() => {
2175
+ const other = this.equals();
2176
+ this.subscription?.unsubscribe();
2177
+ this.subscription = other?.valueChanges.subscribe(() => this.onValidatorChange?.());
2178
+ });
2179
+ inject(DestroyRef).onDestroy(() => this.subscription?.unsubscribe());
2180
+ }
2181
+ /**
2182
+ * Registers the callback Angular invokes to re-run this validator.
2183
+ * @param fn - The revalidation callback provided by the forms API.
2184
+ */
2185
+ registerOnValidatorChange(fn) {
2186
+ this.onValidatorChange = fn;
2239
2187
  }
2240
2188
  /**
2241
2189
  * Validates that the host control value equals the bound control's value.
2242
2190
  * Returns `null` (valid) when no control is bound.
2243
2191
  * @param control - The form control to validate.
2192
+ * @returns `null` when valid, `{ equals: ... }` otherwise.
2244
2193
  */
2245
2194
  validate(control) {
2246
2195
  const eq = this.equals();
@@ -2270,7 +2219,7 @@ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "22.0.1", ngImpor
2270
2219
  ],
2271
2220
  standalone: true,
2272
2221
  }]
2273
- }], propDecorators: { equals: [{ type: i0.Input, args: [{ isSignal: true, alias: "equals", required: false }] }] } });
2222
+ }], ctorParameters: () => [], propDecorators: { equals: [{ type: i0.Input, args: [{ isSignal: true, alias: "equals", required: false }] }] } });
2274
2223
 
2275
2224
  /**
2276
2225
  * Directive that validates a file size against configurable minimum and maximum bounds.
@@ -2454,18 +2403,35 @@ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "22.0.1", ngImpor
2454
2403
  /**
2455
2404
  * Directive that validates that the host control's value is different from another control's value.
2456
2405
  * Bind `[notEqual]="otherControl"`.
2406
+ *
2407
+ * The host control is re-validated whenever the OTHER control's value changes,
2408
+ * so editing either field keeps both error states consistent.
2457
2409
  */
2458
2410
  class NotEqualValidatorDirective {
2459
2411
  constructor() {
2460
2412
  /** The control whose value must differ from the host control's value. */
2461
2413
  this.notEqual = input(undefined, /* @ts-ignore */
2462
2414
  ...(ngDevMode ? [{ debugName: "notEqual" }] : /* istanbul ignore next */ []));
2415
+ effect(() => {
2416
+ const other = this.notEqual();
2417
+ this.subscription?.unsubscribe();
2418
+ this.subscription = other?.valueChanges.subscribe(() => this.onValidatorChange?.());
2419
+ });
2420
+ inject(DestroyRef).onDestroy(() => this.subscription?.unsubscribe());
2421
+ }
2422
+ /**
2423
+ * Registers the callback Angular invokes to re-run this validator.
2424
+ * @param fn - The revalidation callback provided by the forms API.
2425
+ */
2426
+ registerOnValidatorChange(fn) {
2427
+ this.onValidatorChange = fn;
2463
2428
  }
2464
2429
  /**
2465
2430
  * Validates that the host control value is not equal to the bound control's value.
2466
2431
  * Also clears the `notequal` error on the other control when the host becomes valid.
2467
2432
  * Returns `null` (valid) when no control is bound.
2468
2433
  * @param control - The form control to validate.
2434
+ * @returns `null` when valid, `{ notequal: true }` otherwise.
2469
2435
  */
2470
2436
  validate(control) {
2471
2437
  const notEqual = this.notEqual();
@@ -2477,7 +2443,8 @@ class NotEqualValidatorDirective {
2477
2443
  control.markAsTouched();
2478
2444
  }
2479
2445
  else if (notEqual.hasError('notequal')) {
2480
- notEqual.setErrors({ notequal: null });
2446
+ const { notequal: _removed, ...rest } = notEqual.errors ?? {};
2447
+ notEqual.setErrors(Object.keys(rest).length > 0 ? rest : null);
2481
2448
  notEqual.updateValueAndValidity({ onlySelf: true, emitEvent: false });
2482
2449
  notEqual.markAsTouched();
2483
2450
  notEqual.markAsDirty();
@@ -2506,7 +2473,7 @@ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "22.0.1", ngImpor
2506
2473
  ],
2507
2474
  standalone: true,
2508
2475
  }]
2509
- }], propDecorators: { notEqual: [{ type: i0.Input, args: [{ isSignal: true, alias: "notEqual", required: false }] }] } });
2476
+ }], ctorParameters: () => [], propDecorators: { notEqual: [{ type: i0.Input, args: [{ isSignal: true, alias: "notEqual", required: false }] }] } });
2510
2477
 
2511
2478
  /**
2512
2479
  * Directive that validates that a control value is not a future date.
@@ -2522,8 +2489,11 @@ class NotFutureValidatorDirective {
2522
2489
  const input = control.value;
2523
2490
  if (!input || input.length === 0)
2524
2491
  return null;
2492
+ const parsed = SystemUtils.parseDate(input);
2493
+ if (!parsed)
2494
+ return { notFuture: "Non valido." };
2525
2495
  const today = endOfDay(new Date());
2526
- const d = endOfDay(SystemUtils.parseDate(input));
2496
+ const d = endOfDay(parsed);
2527
2497
  return d <= today ? null : { notFuture: "Non valido." };
2528
2498
  }
2529
2499
  static { this.ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "22.0.1", ngImport: i0, type: NotFutureValidatorDirective, deps: [], target: i0.ɵɵFactoryTarget.Directive }); }
@@ -2635,7 +2605,10 @@ class SqlDateValidatorDirective {
2635
2605
  const input = control.value;
2636
2606
  if (!input || input.length === 0)
2637
2607
  return null;
2638
- const d = endOfDay(SystemUtils.parseDate(input));
2608
+ const parsed = SystemUtils.parseDate(input);
2609
+ if (!parsed)
2610
+ return { sqlDate: "Non valido." };
2611
+ const d = endOfDay(parsed);
2639
2612
  return d.getFullYear() > 1750 ? null : { sqlDate: "Non valido." };
2640
2613
  }
2641
2614
  static { this.ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "22.0.1", ngImport: i0, type: SqlDateValidatorDirective, deps: [], target: i0.ɵɵFactoryTarget.Directive }); }
@@ -2681,13 +2654,13 @@ class TimeValidatorDirective {
2681
2654
  const p = value.split(':');
2682
2655
  if (p.length !== 2)
2683
2656
  return -1;
2684
- const hh = parseInt(p[0]);
2685
- if (hh < 0 || hh > 23)
2657
+ const hh = parseInt(p[0], 10);
2658
+ if (isNaN(hh) || hh < 0 || hh > 23)
2686
2659
  return -1;
2687
- const mm = parseInt(p[1]);
2688
- if (mm < 0 || mm > 59)
2660
+ const mm = parseInt(p[1], 10);
2661
+ if (isNaN(mm) || mm < 0 || mm > 59)
2689
2662
  return -1;
2690
- return parseInt(p[0] + p[1]);
2663
+ return hh * 100 + mm;
2691
2664
  }
2692
2665
  /**
2693
2666
  * Validates that the control value is a valid time string and, when slots are configured,
@@ -2735,155 +2708,432 @@ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "22.0.1", ngImpor
2735
2708
  ],
2736
2709
  standalone: true,
2737
2710
  }]
2738
- }], propDecorators: { slots: [{ type: i0.Input, args: [{ isSignal: true, alias: "slots", required: false }] }] } });
2711
+ }], propDecorators: { slots: [{ type: i0.Input, args: [{ isSignal: true, alias: "slots", required: false }] }] } });
2712
+
2713
+ /**
2714
+ * Directive that validates a control value as a well-formed URL.
2715
+ * Apply `url` to a text input that expects a URL.
2716
+ */
2717
+ class UrlValidatorDirective {
2718
+ /**
2719
+ * Validates that the control value is a well-formed URL.
2720
+ * Returns `null` (valid) when the control is empty.
2721
+ * @param control - The form control to validate.
2722
+ */
2723
+ validate(control) {
2724
+ const input = control.value;
2725
+ if (!input || input.length === 0)
2726
+ return null;
2727
+ return SystemUtils.parseUrl(input) ? null : { url: "Non valido." };
2728
+ }
2729
+ static { this.ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "22.0.1", ngImport: i0, type: UrlValidatorDirective, deps: [], target: i0.ɵɵFactoryTarget.Directive }); }
2730
+ static { this.ɵdir = i0.ɵɵngDeclareDirective({ minVersion: "14.0.0", version: "22.0.1", type: UrlValidatorDirective, isStandalone: true, selector: "[url]", providers: [
2731
+ {
2732
+ provide: NG_VALIDATORS,
2733
+ useExisting: forwardRef(() => UrlValidatorDirective),
2734
+ multi: true,
2735
+ },
2736
+ ], ngImport: i0 }); }
2737
+ }
2738
+ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "22.0.1", ngImport: i0, type: UrlValidatorDirective, decorators: [{
2739
+ type: Directive,
2740
+ args: [{
2741
+ selector: "[url]",
2742
+ providers: [
2743
+ {
2744
+ provide: NG_VALIDATORS,
2745
+ useExisting: forwardRef(() => UrlValidatorDirective),
2746
+ multi: true,
2747
+ },
2748
+ ],
2749
+ standalone: true,
2750
+ }]
2751
+ }] });
2752
+
2753
+ /**
2754
+ * Directive that validates a control using the host object's `isValid()` method
2755
+ * or a boolean expression passed via `[validIf]`.
2756
+ */
2757
+ class ValidIfDirective {
2758
+ constructor() {
2759
+ /** When `true`, the control is considered valid regardless of the bound value. */
2760
+ this.validIf = input(false, /* @ts-ignore */
2761
+ ...(ngDevMode ? [{ debugName: "validIf" }] : /* istanbul ignore next */ []));
2762
+ }
2763
+ /**
2764
+ * Validates the control value against a boolean flag or the value's own `isValid()` method.
2765
+ * @param control - The form control to validate.
2766
+ */
2767
+ validate(control) {
2768
+ let isValid = false;
2769
+ const c = control.value ? control.value : null;
2770
+ if (!c) {
2771
+ isValid = this.validIf() === true;
2772
+ }
2773
+ else {
2774
+ try {
2775
+ isValid = c.isValid();
2776
+ }
2777
+ catch { }
2778
+ }
2779
+ return isValid ? null : { validIf: "Non valido." };
2780
+ }
2781
+ static { this.ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "22.0.1", ngImport: i0, type: ValidIfDirective, deps: [], target: i0.ɵɵFactoryTarget.Directive }); }
2782
+ static { this.ɵdir = i0.ɵɵngDeclareDirective({ minVersion: "17.1.0", version: "22.0.1", type: ValidIfDirective, isStandalone: true, selector: "[validIf]", inputs: { validIf: { classPropertyName: "validIf", publicName: "validIf", isSignal: true, isRequired: false, transformFunction: null } }, providers: [
2783
+ {
2784
+ provide: NG_VALIDATORS,
2785
+ useExisting: forwardRef(() => ValidIfDirective),
2786
+ multi: true,
2787
+ },
2788
+ ], ngImport: i0 }); }
2789
+ }
2790
+ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "22.0.1", ngImport: i0, type: ValidIfDirective, decorators: [{
2791
+ type: Directive,
2792
+ args: [{
2793
+ selector: "[validIf]",
2794
+ providers: [
2795
+ {
2796
+ provide: NG_VALIDATORS,
2797
+ useExisting: forwardRef(() => ValidIfDirective),
2798
+ multi: true,
2799
+ },
2800
+ ],
2801
+ standalone: true,
2802
+ }]
2803
+ }], propDecorators: { validIf: [{ type: i0.Input, args: [{ isSignal: true, alias: "validIf", required: false }] }] } });
2804
+
2805
+ /**
2806
+ * Directive that delegates validation to an externally provided validator function.
2807
+ * Bind `[validator]="myFn"` where `myFn` is `(c: AbstractControl) => ValidationErrors | null`.
2808
+ */
2809
+ class ValidatorDirective {
2810
+ constructor() {
2811
+ /** The custom validator function to apply. */
2812
+ this.validator = input(undefined, /* @ts-ignore */
2813
+ ...(ngDevMode ? [{ debugName: "validator" }] : /* istanbul ignore next */ []));
2814
+ }
2815
+ /**
2816
+ * Invokes the provided validator function against the given control.
2817
+ * Returns `null` (valid) when no function is bound.
2818
+ * @param control - The form control to validate.
2819
+ */
2820
+ validate(control) {
2821
+ const fn = this.validator();
2822
+ return fn ? fn(control) : null;
2823
+ }
2824
+ static { this.ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "22.0.1", ngImport: i0, type: ValidatorDirective, deps: [], target: i0.ɵɵFactoryTarget.Directive }); }
2825
+ static { this.ɵdir = i0.ɵɵngDeclareDirective({ minVersion: "17.1.0", version: "22.0.1", type: ValidatorDirective, isStandalone: true, selector: "[validator]", inputs: { validator: { classPropertyName: "validator", publicName: "validator", isSignal: true, isRequired: false, transformFunction: null } }, providers: [{ provide: NG_VALIDATORS, useExisting: ValidatorDirective, multi: true }], ngImport: i0 }); }
2826
+ }
2827
+ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "22.0.1", ngImport: i0, type: ValidatorDirective, decorators: [{
2828
+ type: Directive,
2829
+ args: [{
2830
+ selector: '[validator]',
2831
+ providers: [{ provide: NG_VALIDATORS, useExisting: ValidatorDirective, multi: true }],
2832
+ standalone: true,
2833
+ }]
2834
+ }], propDecorators: { validator: [{ type: i0.Input, args: [{ isSignal: true, alias: "validator", required: false }] }] } });
2835
+
2836
+ /**
2837
+ * Pipe that converts plain-text newlines (`\r\n`, `\r`, `\n`) to HTML `<br>` tags
2838
+ * and marks the result as trusted HTML so Angular does not escape it.
2839
+ *
2840
+ * Usage: `{{ text | formatHtml }}`
2841
+ */
2842
+ class FormatHtmlPipe {
2843
+ constructor() {
2844
+ this.sanitizer = inject(DomSanitizer);
2845
+ }
2846
+ /**
2847
+ * Transforms a plain-text string into sanitized HTML by replacing newline characters
2848
+ * with `<br>` tags.
2849
+ * @param value - The input string to transform. Treated as an empty string when `undefined`.
2850
+ * @returns A `SafeHtml` value that can be rendered with `[innerHTML]`.
2851
+ */
2852
+ transform(value) {
2853
+ const escaped = (value ?? '')
2854
+ .replaceAll('&', '&amp;')
2855
+ .replaceAll('<', '&lt;')
2856
+ .replaceAll('>', '&gt;')
2857
+ .replaceAll('"', '&quot;');
2858
+ return this.sanitizer.bypassSecurityTrustHtml(escaped.replaceAll(/(?:\r\n|\r|\n)/g, '<br>'));
2859
+ }
2860
+ static { this.ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "22.0.1", ngImport: i0, type: FormatHtmlPipe, deps: [], target: i0.ɵɵFactoryTarget.Pipe }); }
2861
+ static { this.ɵpipe = i0.ɵɵngDeclarePipe({ minVersion: "14.0.0", version: "22.0.1", ngImport: i0, type: FormatHtmlPipe, isStandalone: true, name: "formatHtml" }); }
2862
+ }
2863
+ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "22.0.1", ngImport: i0, type: FormatHtmlPipe, decorators: [{
2864
+ type: Pipe,
2865
+ args: [{
2866
+ name: 'formatHtml',
2867
+ standalone: true
2868
+ }]
2869
+ }] });
2870
+
2871
+ /**
2872
+ * Pipe that converts a Markdown string to sanitized HTML using `SystemUtils.markdownToHtml`.
2873
+ *
2874
+ * Usage: `{{ text | formatMarkdown }}`
2875
+ */
2876
+ class FormatMarkdownPipe {
2877
+ constructor() {
2878
+ this.sanitizer = inject(DomSanitizer);
2879
+ }
2880
+ /**
2881
+ * Transforms a Markdown string into sanitized HTML.
2882
+ * @param value - The Markdown input to convert. Treated as an empty string when `undefined`.
2883
+ * @returns A `SafeHtml` value that can be rendered with `[innerHTML]`.
2884
+ */
2885
+ transform(value) {
2886
+ return this.sanitizer.bypassSecurityTrustHtml(SystemUtils.markdownToHtml(value ?? ''));
2887
+ }
2888
+ static { this.ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "22.0.1", ngImport: i0, type: FormatMarkdownPipe, deps: [], target: i0.ɵɵFactoryTarget.Pipe }); }
2889
+ static { this.ɵpipe = i0.ɵɵngDeclarePipe({ minVersion: "14.0.0", version: "22.0.1", ngImport: i0, type: FormatMarkdownPipe, isStandalone: true, name: "formatMarkdown" }); }
2890
+ }
2891
+ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "22.0.1", ngImport: i0, type: FormatMarkdownPipe, decorators: [{
2892
+ type: Pipe,
2893
+ args: [{
2894
+ name: 'formatMarkdown',
2895
+ standalone: true
2896
+ }]
2897
+ }] });
2898
+
2899
+ /**
2900
+ * General-purpose formatting pipe that converts a raw value to a locale-aware string
2901
+ * based on the specified format type.
2902
+ *
2903
+ * Supported types: `'date'` / `'D'`, `'currency'` / `'C'`, `'number'` / `'N'`,
2904
+ * `'number0'` / `'N0'`, `'percentage'` / `'P'`.
2905
+ *
2906
+ * Usage: `{{ value | format:'currency' }}`
2907
+ */
2908
+ class FormatPipe {
2909
+ /** Shared formatters: building an `Intl.NumberFormat` per call is expensive. */
2910
+ static { this.currencyFormat = new Intl.NumberFormat('it-IT', { style: 'currency', currency: 'EUR' }); }
2911
+ static { this.numberFormat = new Intl.NumberFormat('it-IT', { style: 'decimal', minimumFractionDigits: 0, maximumFractionDigits: 2 }); }
2912
+ static { this.number0Format = new Intl.NumberFormat('it-IT', { style: 'decimal', minimumFractionDigits: 0, maximumFractionDigits: 0 }); }
2913
+ static { this.percentFormat = new Intl.NumberFormat('it-IT', { style: 'percent', minimumFractionDigits: 0, maximumFractionDigits: 0 }); }
2914
+ /**
2915
+ * Formats a value according to the specified type and optional pattern.
2916
+ * Returns `undefined` when the value is `null` or `undefined`, or when the type is unrecognised.
2917
+ * @param value - The raw value to format.
2918
+ * @param type - The format type identifier (default: `'date'`).
2919
+ * @param pattern - The date pattern used when `type` is `'date'` (default: `'dd/MM/yyyy'`).
2920
+ * @returns A formatted string, or `undefined` when the value cannot be formatted.
2921
+ */
2922
+ transform(value, type = 'date', pattern = 'dd/MM/yyyy') {
2923
+ if (value === undefined || value === null)
2924
+ return undefined;
2925
+ switch (type) {
2926
+ case 'D':
2927
+ case 'date': {
2928
+ const d = SystemUtils.parseDate(value, it);
2929
+ if (d)
2930
+ return format(d, pattern, { locale: it });
2931
+ break;
2932
+ }
2933
+ case 'C':
2934
+ case 'currency':
2935
+ return FormatPipe.currencyFormat.format(value);
2936
+ case 'N':
2937
+ case 'number':
2938
+ return FormatPipe.numberFormat.format(value);
2939
+ case 'N0':
2940
+ case 'number0':
2941
+ return FormatPipe.number0Format.format(value);
2942
+ case 'P':
2943
+ case 'percentage':
2944
+ return FormatPipe.percentFormat.format(value);
2945
+ }
2946
+ return undefined;
2947
+ }
2948
+ static { this.ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "22.0.1", ngImport: i0, type: FormatPipe, deps: [], target: i0.ɵɵFactoryTarget.Pipe }); }
2949
+ static { this.ɵpipe = i0.ɵɵngDeclarePipe({ minVersion: "14.0.0", version: "22.0.1", ngImport: i0, type: FormatPipe, isStandalone: true, name: "format" }); }
2950
+ }
2951
+ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "22.0.1", ngImport: i0, type: FormatPipe, decorators: [{
2952
+ type: Pipe,
2953
+ args: [{
2954
+ name: 'format',
2955
+ standalone: true
2956
+ }]
2957
+ }] });
2739
2958
 
2740
2959
  /**
2741
- * Directive that validates a control value as a well-formed URL.
2742
- * Apply `url` to a text input that expects a URL.
2960
+ * Pipe that applies a global regex replacement on a string and returns the result
2961
+ * as sanitized HTML. When `regexValue` is `'\n'` and no `replaceValue` is given,
2962
+ * newlines are replaced with `<br>` tags.
2963
+ *
2964
+ * Usage: `{{ text | replace:'\n':'' }}`
2743
2965
  */
2744
- class UrlValidatorDirective {
2966
+ class ReplacePipe {
2967
+ constructor() {
2968
+ this.sanitizer = inject(DomSanitizer);
2969
+ }
2745
2970
  /**
2746
- * Validates that the control value is a well-formed URL.
2747
- * Returns `null` (valid) when the control is empty.
2748
- * @param control - The form control to validate.
2971
+ * Replaces all occurrences of `regexValue` in `value` with `replaceValue`.
2972
+ * Returns `undefined` when `value` is empty or `undefined`.
2973
+ * @param value - The source string to process.
2974
+ * @param regexValue - The regex pattern string to match (applied with the global flag).
2975
+ * @param replaceValue - The replacement string. Defaults to `'<br>'` when `regexValue` is `'\n'` and this is falsy.
2976
+ * @returns A `SafeHtml` value with all matches replaced, or `undefined` when the input is empty.
2749
2977
  */
2750
- validate(control) {
2751
- const input = control.value;
2752
- if (!input || input.length === 0)
2753
- return null;
2754
- return SystemUtils.parseUrl(input) ? null : { url: "Non valido." };
2978
+ transform(value, regexValue, replaceValue) {
2979
+ if (!value)
2980
+ return undefined;
2981
+ const replacement = (regexValue === '\n' && !replaceValue) ? '<br>' : (replaceValue ?? '');
2982
+ return this.sanitizer.bypassSecurityTrustHtml(value.replace(new RegExp(regexValue, 'g'), replacement));
2755
2983
  }
2756
- static { this.ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "22.0.1", ngImport: i0, type: UrlValidatorDirective, deps: [], target: i0.ɵɵFactoryTarget.Directive }); }
2757
- static { this.ɵdir = i0.ɵɵngDeclareDirective({ minVersion: "14.0.0", version: "22.0.1", type: UrlValidatorDirective, isStandalone: true, selector: "[url]", providers: [
2758
- {
2759
- provide: NG_VALIDATORS,
2760
- useExisting: forwardRef(() => UrlValidatorDirective),
2761
- multi: true,
2762
- },
2763
- ], ngImport: i0 }); }
2984
+ static { this.ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "22.0.1", ngImport: i0, type: ReplacePipe, deps: [], target: i0.ɵɵFactoryTarget.Pipe }); }
2985
+ static { this.ɵpipe = i0.ɵɵngDeclarePipe({ minVersion: "14.0.0", version: "22.0.1", ngImport: i0, type: ReplacePipe, isStandalone: true, name: "replace" }); }
2764
2986
  }
2765
- i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "22.0.1", ngImport: i0, type: UrlValidatorDirective, decorators: [{
2766
- type: Directive,
2987
+ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "22.0.1", ngImport: i0, type: ReplacePipe, decorators: [{
2988
+ type: Pipe,
2767
2989
  args: [{
2768
- selector: "[url]",
2769
- providers: [
2770
- {
2771
- provide: NG_VALIDATORS,
2772
- useExisting: forwardRef(() => UrlValidatorDirective),
2773
- multi: true,
2774
- },
2775
- ],
2776
- standalone: true,
2990
+ name: 'replace',
2991
+ standalone: true
2777
2992
  }]
2778
2993
  }] });
2779
2994
 
2780
2995
  /**
2781
- * Directive that validates a control using the host object's `isValid()` method
2782
- * or a boolean expression passed via `[validIf]`.
2996
+ * Pipe that marks an HTML string as trusted so Angular does not escape it when
2997
+ * bound via `[innerHTML]`.
2998
+ *
2999
+ * Usage: `<div [innerHTML]="html | safeHtml"></div>`
2783
3000
  */
2784
- class ValidIfDirective {
3001
+ class SafeHtmlPipe {
2785
3002
  constructor() {
2786
- /** When `true`, the control is considered valid regardless of the bound value. */
2787
- this.validIf = input(false, /* @ts-ignore */
2788
- ...(ngDevMode ? [{ debugName: "validIf" }] : /* istanbul ignore next */ []));
3003
+ this.sanitizer = inject(DomSanitizer);
2789
3004
  }
2790
3005
  /**
2791
- * Validates the control value against a boolean flag or the value's own `isValid()` method.
2792
- * @param control - The form control to validate.
3006
+ * Bypasses Angular's HTML sanitization and returns a `SafeHtml` instance.
3007
+ * @param value - The raw HTML string to trust. Treated as an empty string when `undefined`.
3008
+ * @returns A `SafeHtml` value that can be bound to `[innerHTML]` without escaping.
2793
3009
  */
2794
- validate(control) {
2795
- let isValid = false;
2796
- const c = control.value ? control.value : null;
2797
- if (!c) {
2798
- isValid = this.validIf() === true;
2799
- }
2800
- else {
2801
- try {
2802
- isValid = c.isValid();
2803
- }
2804
- catch { }
2805
- }
2806
- return isValid ? null : { validIf: "Non valido." };
3010
+ transform(value) {
3011
+ return this.sanitizer.bypassSecurityTrustHtml(value ?? '');
2807
3012
  }
2808
- static { this.ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "22.0.1", ngImport: i0, type: ValidIfDirective, deps: [], target: i0.ɵɵFactoryTarget.Directive }); }
2809
- static { this.ɵdir = i0.ɵɵngDeclareDirective({ minVersion: "17.1.0", version: "22.0.1", type: ValidIfDirective, isStandalone: true, selector: "[validIf]", inputs: { validIf: { classPropertyName: "validIf", publicName: "validIf", isSignal: true, isRequired: false, transformFunction: null } }, providers: [
2810
- {
2811
- provide: NG_VALIDATORS,
2812
- useExisting: forwardRef(() => ValidIfDirective),
2813
- multi: true,
2814
- },
2815
- ], ngImport: i0 }); }
3013
+ static { this.ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "22.0.1", ngImport: i0, type: SafeHtmlPipe, deps: [], target: i0.ɵɵFactoryTarget.Pipe }); }
3014
+ static { this.ɵpipe = i0.ɵɵngDeclarePipe({ minVersion: "14.0.0", version: "22.0.1", ngImport: i0, type: SafeHtmlPipe, isStandalone: true, name: "safeHtml" }); }
2816
3015
  }
2817
- i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "22.0.1", ngImport: i0, type: ValidIfDirective, decorators: [{
2818
- type: Directive,
3016
+ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "22.0.1", ngImport: i0, type: SafeHtmlPipe, decorators: [{
3017
+ type: Pipe,
2819
3018
  args: [{
2820
- selector: "[validIf]",
2821
- providers: [
2822
- {
2823
- provide: NG_VALIDATORS,
2824
- useExisting: forwardRef(() => ValidIfDirective),
2825
- multi: true,
2826
- },
2827
- ],
2828
- standalone: true,
3019
+ name: 'safeHtml',
3020
+ standalone: true
2829
3021
  }]
2830
- }], propDecorators: { validIf: [{ type: i0.Input, args: [{ isSignal: true, alias: "validIf", required: false }] }] } });
3022
+ }] });
2831
3023
 
2832
3024
  /**
2833
- * Directive that delegates validation to an externally provided validator function.
2834
- * Bind `[validator]="myFn"` where `myFn` is `(c: AbstractControl) => ValidationErrors | null`.
3025
+ * Pipe that marks a URL string as a trusted resource URL so Angular does not block it
3026
+ * when bound to attributes such as `[src]` or `[href]` on iframes, objects, or embeds.
3027
+ *
3028
+ * Usage: `<iframe [src]="url | safeUrl"></iframe>`
2835
3029
  */
2836
- class ValidatorDirective {
3030
+ class SafeUrlPipe {
2837
3031
  constructor() {
2838
- /** The custom validator function to apply. */
2839
- this.validator = input(undefined, /* @ts-ignore */
2840
- ...(ngDevMode ? [{ debugName: "validator" }] : /* istanbul ignore next */ []));
3032
+ this.sanitizer = inject(DomSanitizer);
2841
3033
  }
2842
3034
  /**
2843
- * Invokes the provided validator function against the given control.
2844
- * Returns `null` (valid) when no function is bound.
2845
- * @param control - The form control to validate.
3035
+ * Bypasses Angular's resource-URL sanitization and returns a `SafeResourceUrl` instance.
3036
+ * @param value - The URL string to trust. Treated as an empty string when `undefined`.
3037
+ * @returns A `SafeResourceUrl` that can be bound to resource URL attributes without blocking.
2846
3038
  */
2847
- validate(control) {
2848
- const fn = this.validator();
2849
- return fn ? fn(control) : null;
3039
+ transform(value) {
3040
+ return this.sanitizer.bypassSecurityTrustResourceUrl(value ?? '');
2850
3041
  }
2851
- static { this.ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "22.0.1", ngImport: i0, type: ValidatorDirective, deps: [], target: i0.ɵɵFactoryTarget.Directive }); }
2852
- static { this.ɵdir = i0.ɵɵngDeclareDirective({ minVersion: "17.1.0", version: "22.0.1", type: ValidatorDirective, isStandalone: true, selector: "[validator]", inputs: { validator: { classPropertyName: "validator", publicName: "validator", isSignal: true, isRequired: false, transformFunction: null } }, providers: [{ provide: NG_VALIDATORS, useExisting: ValidatorDirective, multi: true }], ngImport: i0 }); }
3042
+ static { this.ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "22.0.1", ngImport: i0, type: SafeUrlPipe, deps: [], target: i0.ɵɵFactoryTarget.Pipe }); }
3043
+ static { this.ɵpipe = i0.ɵɵngDeclarePipe({ minVersion: "14.0.0", version: "22.0.1", ngImport: i0, type: SafeUrlPipe, isStandalone: true, name: "safeUrl" }); }
2853
3044
  }
2854
- i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "22.0.1", ngImport: i0, type: ValidatorDirective, decorators: [{
2855
- type: Directive,
3045
+ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "22.0.1", ngImport: i0, type: SafeUrlPipe, decorators: [{
3046
+ type: Pipe,
2856
3047
  args: [{
2857
- selector: '[validator]',
2858
- providers: [{ provide: NG_VALIDATORS, useExisting: ValidatorDirective, multi: true }],
2859
- standalone: true,
3048
+ name: 'safeUrl',
3049
+ standalone: true
2860
3050
  }]
2861
- }], propDecorators: { validator: [{ type: i0.Input, args: [{ isSignal: true, alias: "validator", required: false }] }] } });
3051
+ }] });
2862
3052
 
2863
3053
  /**
2864
- * Pipe that converts a Markdown string to sanitized HTML using `SystemUtils.markdownToHtml`.
3054
+ * Impure pipe that filters an array using a caller-provided predicate function.
3055
+ * Because the pipe is impure it re-evaluates on every change-detection cycle,
3056
+ * which is necessary when the predicate's captured state changes.
2865
3057
  *
2866
- * Usage: `{{ text | formatMarkdown }}`
3058
+ * Usage: `*ngFor="let item of items | callback:myFilter"`
2867
3059
  */
2868
- class FormatMarkdownPipe {
2869
- constructor() {
2870
- this.sanitizer = inject(DomSanitizer);
3060
+ class SearchCallbackPipe {
3061
+ /**
3062
+ * Filters `items` by applying `callback` to each element.
3063
+ * Returns the original array unchanged when either argument is falsy.
3064
+ * @param items - The source array to filter. May be `undefined`.
3065
+ * @param callback - A predicate function that returns `true` for items to keep.
3066
+ * @returns A new filtered array, the original array when no callback is provided,
3067
+ * or `undefined` when `items` is `undefined`.
3068
+ */
3069
+ transform(items, callback) {
3070
+ if (!items || !callback)
3071
+ return items;
3072
+ return items.filter(item => callback(item));
2871
3073
  }
3074
+ static { this.ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "22.0.1", ngImport: i0, type: SearchCallbackPipe, deps: [], target: i0.ɵɵFactoryTarget.Pipe }); }
3075
+ static { this.ɵpipe = i0.ɵɵngDeclarePipe({ minVersion: "14.0.0", version: "22.0.1", ngImport: i0, type: SearchCallbackPipe, isStandalone: true, name: "callback", pure: false }); }
3076
+ }
3077
+ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "22.0.1", ngImport: i0, type: SearchCallbackPipe, decorators: [{
3078
+ type: Pipe,
3079
+ args: [{
3080
+ name: 'callback',
3081
+ pure: false,
3082
+ standalone: true
3083
+ }]
3084
+ }] });
3085
+
3086
+ /**
3087
+ * Pure pipe that filters an array of searchable items against a text query.
3088
+ * Note: being pure, it re-runs only when the array REFERENCE or the query
3089
+ * changes; mutate-in-place updates of the array are not detected.
3090
+ *
3091
+ * Each item is matched either via its `searchBag.name` property (when present)
3092
+ * or by converting the item itself to a lowercase string. The optional `metadata`
3093
+ * argument is updated in-place with the total item count and the filtered count,
3094
+ * making it usable in the template alongside `*ngFor`.
3095
+ *
3096
+ * Usage:
3097
+ * ```html
3098
+ * <div *ngFor="let item of items | search:filterText:meta">...</div>
3099
+ * <div>Showing {{ meta.count }} of {{ meta.total }}</div>
3100
+ * ```
3101
+ */
3102
+ class SearchFilterPipe {
2872
3103
  /**
2873
- * Transforms a Markdown string into sanitized HTML.
2874
- * @param value - The Markdown input to convert. Treated as an empty string when `undefined`.
2875
- * @returns A `SafeHtml` value that can be rendered with `[innerHTML]`.
3104
+ * Filters `items` by performing a case-insensitive substring match against `value`.
3105
+ * When `items` or `value` is falsy the original array is returned unfiltered.
3106
+ * @param items - The source array to filter. May be `undefined`.
3107
+ * @param value - The search text to match against each item. May be `undefined`.
3108
+ * @param metadata - Optional object that is updated with `total` and `count` after filtering.
3109
+ * @returns The filtered array, the original array when no filter text is given,
3110
+ * or `undefined` when `items` is `undefined`.
2876
3111
  */
2877
- transform(value) {
2878
- return this.sanitizer.bypassSecurityTrustHtml(SystemUtils.markdownToHtml(value ?? ''));
3112
+ transform(items, value, metadata) {
3113
+ metadata ??= { total: 0, count: 0 };
3114
+ if (!items || !value) {
3115
+ metadata.total = items?.length ?? 0;
3116
+ metadata.count = metadata.total;
3117
+ return items;
3118
+ }
3119
+ const query = value.toLowerCase();
3120
+ const result = items.filter(item => {
3121
+ if (!item)
3122
+ return false;
3123
+ const text = item.searchBag?.name ?? (typeof item === 'string' ? item : undefined);
3124
+ return text?.toLowerCase().includes(query) ?? false;
3125
+ });
3126
+ metadata.total = items.length;
3127
+ metadata.count = result.length;
3128
+ return result;
2879
3129
  }
2880
- static { this.ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "22.0.1", ngImport: i0, type: FormatMarkdownPipe, deps: [], target: i0.ɵɵFactoryTarget.Pipe }); }
2881
- static { this.ɵpipe = i0.ɵɵngDeclarePipe({ minVersion: "14.0.0", version: "22.0.1", ngImport: i0, type: FormatMarkdownPipe, isStandalone: true, name: "formatMarkdown" }); }
3130
+ static { this.ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "22.0.1", ngImport: i0, type: SearchFilterPipe, deps: [], target: i0.ɵɵFactoryTarget.Pipe }); }
3131
+ static { this.ɵpipe = i0.ɵɵngDeclarePipe({ minVersion: "14.0.0", version: "22.0.1", ngImport: i0, type: SearchFilterPipe, isStandalone: true, name: "search" }); }
2882
3132
  }
2883
- i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "22.0.1", ngImport: i0, type: FormatMarkdownPipe, decorators: [{
3133
+ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "22.0.1", ngImport: i0, type: SearchFilterPipe, decorators: [{
2884
3134
  type: Pipe,
2885
3135
  args: [{
2886
- name: 'formatMarkdown',
3136
+ name: 'search',
2887
3137
  standalone: true
2888
3138
  }]
2889
3139
  }] });
@@ -2895,7 +3145,8 @@ const Breakpoints = {
2895
3145
  SmallMedium: '(min-width: 600px) and (max-width: 1059.98px)',
2896
3146
  Medium: '(min-width: 960px) and (max-width: 1279.98px)',
2897
3147
  MediumLarge: '(min-width: 960px) and (max-width: 1459.98px)',
2898
- Large: '(min-width: 1460px) and (max-width: 1919.98px)'
3148
+ Large: '(min-width: 1460px) and (max-width: 1919.98px)',
3149
+ XLarge: '(min-width: 1920px)'
2899
3150
  };
2900
3151
 
2901
3152
  const UtilsMessages = {
@@ -2927,20 +3178,32 @@ class SelectableModel {
2927
3178
  }
2928
3179
  /**
2929
3180
  * @param allowMultiSelect - When `true` (default), multiple items can be selected simultaneously.
2930
- * @param lookupFieldName - Name of the note used as the unique key when searching the internal list (default: `'id'`).
3181
+ * @param lookupFieldName - Name of the field used as the unique key when searching the internal list
3182
+ * (default: `'id'`). Keys of `T` are autocompleted; plain strings remain accepted.
2931
3183
  */
2932
3184
  constructor(allowMultiSelect = true, lookupFieldName = 'id') {
2933
3185
  /**
2934
3186
  * Emits whenever the selection changes.
2935
3187
  * Carries the affected item, or `undefined` when the whole selection is cleared.
3188
+ *
3189
+ * Note: kept as `EventEmitter` (instead of a plain RxJS `Subject`) for
3190
+ * backward compatibility with existing consumers; `EventEmitter` extends
3191
+ * `Subject`, so `.subscribe()`, `pipe(...)` and `takeUntilDestroyed` all
3192
+ * work as expected.
2936
3193
  */
2937
3194
  this.changed = new EventEmitter();
2938
3195
  this._all = signal([], /* @ts-ignore */
2939
3196
  ...(ngDevMode ? [{ debugName: "_all" }] : /* istanbul ignore next */ []));
2940
- /** Signal that is `true` when at least one item is selected. */
3197
+ /** Signal that is `true` when at least one item is tracked in the internal list. */
2941
3198
  this.hasValue = computed(() => this._all().length > 0, /* @ts-ignore */
2942
3199
  ...(ngDevMode ? [{ debugName: "hasValue" }] : /* istanbul ignore next */ []));
2943
- this._current = new SelectionModel(allowMultiSelect, []);
3200
+ /** Signal with the number of items currently tracked in the internal list. */
3201
+ this.count = computed(() => this._all().length, /* @ts-ignore */
3202
+ ...(ngDevMode ? [{ debugName: "count" }] : /* istanbul ignore next */ []));
3203
+ this._current = new SelectionModel(allowMultiSelect, [], true,
3204
+ // Compare by lookup key so different instances of the same logical item
3205
+ // (e.g. after a data reload) are treated as the same selection entry.
3206
+ (a, b) => a[lookupFieldName] === b[lookupFieldName]);
2944
3207
  this._lookupFieldName = lookupFieldName;
2945
3208
  }
2946
3209
  /**
@@ -3071,6 +3334,27 @@ class BroadcastChannelManager {
3071
3334
  get currentBus() {
3072
3335
  return this.channel?.name;
3073
3336
  }
3337
+ /**
3338
+ * Observable that emits EVERY message bag received on the channel,
3339
+ * regardless of `messageId`. Completed by {@link dispose}.
3340
+ * Prefer {@link observe} to listen for a single message type.
3341
+ */
3342
+ get messages() {
3343
+ return this.subject.asObservable();
3344
+ }
3345
+ /**
3346
+ * Returns a typed Observable that emits only the messages with the given `messageId`.
3347
+ *
3348
+ * Unlike {@link subscribe} (one callback per id, replaced on re-registration),
3349
+ * this supports any number of concurrent subscribers and integrates with the
3350
+ * RxJS lifecycle (`takeUntilDestroyed`, `async` pipe, ...). Completed by {@link dispose}.
3351
+ *
3352
+ * @param messageId - The message type identifier to listen for.
3353
+ * @returns An Observable of the matching typed message bags.
3354
+ */
3355
+ observe(messageId) {
3356
+ return this.subject.pipe(filter(bag => bag.messageId === messageId), map(bag => bag));
3357
+ }
3074
3358
  /**
3075
3359
  * Opens a `BroadcastChannel` with the given name (when the API is available).
3076
3360
  * @param bus - The channel name to open (default: `'ARS-CHANNEL'`).
@@ -3224,6 +3508,16 @@ class BroadcastService {
3224
3508
  unsubscribeChannelMessage(id) {
3225
3509
  this.channel.unsubscribe(id);
3226
3510
  }
3511
+ /**
3512
+ * Returns a typed Observable of the cross-tab channel messages with the given `id`.
3513
+ * Supports multiple concurrent subscribers and the RxJS lifecycle operators,
3514
+ * unlike `subscribeChannelMessage` (single callback per id).
3515
+ * @param id - The message type identifier to listen for.
3516
+ * @returns An Observable of the matching typed message bags.
3517
+ */
3518
+ observeChannelMessage(id) {
3519
+ return this.channel.observe(id);
3520
+ }
3227
3521
  /**
3228
3522
  * Returns an `Observable` that emits every in-process message published via `sendMessage`.
3229
3523
  * @returns An observable stream of `BroadcastMessageInfo` objects.
@@ -3231,6 +3525,14 @@ class BroadcastService {
3231
3525
  getMessage() {
3232
3526
  return this.subject.asObservable();
3233
3527
  }
3528
+ /**
3529
+ * Returns a typed `Observable` of the in-process messages with the given `id`.
3530
+ * @param id - The message type identifier to listen for.
3531
+ * @returns An Observable emitting the typed `data` payload of each matching message.
3532
+ */
3533
+ observeMessage(id) {
3534
+ return this.subject.pipe(filter$1(info => info.id === id), map$1(info => info.data));
3535
+ }
3234
3536
  static { this.ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "22.0.1", ngImport: i0, type: BroadcastService, deps: [], target: i0.ɵɵFactoryTarget.Service }); }
3235
3537
  static { this.ɵprov = i0.ɵɵngDeclareService({ minVersion: "22.0.0", version: "22.0.1", ngImport: i0, type: BroadcastService }); }
3236
3538
  }
@@ -3324,7 +3626,7 @@ class ScreenService {
3324
3626
  * Modern Chromium-based Edge reports a different user-agent and will return `false`.
3325
3627
  */
3326
3628
  get isIEOrEdge() {
3327
- return /msie\s|trident\/|edge\//i.test(window.navigator.userAgent);
3629
+ return SystemUtils.isBrowser() && /msie\s|trident\/|edge\//i.test(window.navigator.userAgent);
3328
3630
  }
3329
3631
  static { this.ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "22.0.1", ngImport: i0, type: ScreenService, deps: [], target: i0.ɵɵFactoryTarget.Service }); }
3330
3632
  static { this.ɵprov = i0.ɵɵngDeclareService({ minVersion: "22.0.0", version: "22.0.1", ngImport: i0, type: ScreenService }); }
@@ -3379,7 +3681,9 @@ class ThemeService {
3379
3681
  constructor() {
3380
3682
  this.broadcastChannel = new BroadcastChannelManager('THEME-SERVICE');
3381
3683
  this.broadcastMessage = '$theme-changed';
3382
- this.prefersColorSchemeMediaQueryList = window.matchMedia('(prefers-color-scheme: dark)');
3684
+ this.prefersColorSchemeMediaQueryList = typeof window !== 'undefined' && typeof window.matchMedia === 'function'
3685
+ ? window.matchMedia('(prefers-color-scheme: dark)')
3686
+ : undefined;
3383
3687
  this.themeChanged = new BehaviorSubject('auto');
3384
3688
  this.renderFactory2 = inject(RendererFactory2);
3385
3689
  this.renderer = this.renderFactory2.createRenderer(null, null);
@@ -3412,10 +3716,12 @@ class ThemeService {
3412
3716
  * @param theme - The initial theme to apply (defaults to the value stored in `localStorage`).
3413
3717
  */
3414
3718
  initialize(theme = this.getPreferredTheme()) {
3415
- this.prefersColorSchemeMediaQueryList.onchange = () => {
3416
- if (this.auto())
3417
- this.setTheme(this.theme());
3418
- };
3719
+ if (this.prefersColorSchemeMediaQueryList) {
3720
+ this.prefersColorSchemeMediaQueryList.onchange = () => {
3721
+ if (this.auto())
3722
+ this.setTheme(this.theme());
3723
+ };
3724
+ }
3419
3725
  this.broadcastChannel.subscribe({
3420
3726
  messageId: this.broadcastMessage,
3421
3727
  action: (bag) => {
@@ -3441,10 +3747,11 @@ class ThemeService {
3441
3747
  * @returns `'light'` or `'dark'` — never `'auto'`.
3442
3748
  */
3443
3749
  getTheme() {
3444
- if (this.auto()) {
3445
- return this.prefersColorSchemeMediaQueryList.matches ? 'dark' : 'light';
3750
+ const current = this.theme();
3751
+ if (current === 'auto') {
3752
+ return this.prefersColorSchemeMediaQueryList?.matches ? 'dark' : 'light';
3446
3753
  }
3447
- return this.getPreferredTheme();
3754
+ return current;
3448
3755
  }
3449
3756
  /**
3450
3757
  * Reads the theme stored in `localStorage` and validates it.
@@ -3452,6 +3759,8 @@ class ThemeService {
3452
3759
  * @returns The persisted `ThemeType`, or `'auto'` as default.
3453
3760
  */
3454
3761
  getPreferredTheme() {
3762
+ if (typeof localStorage === 'undefined')
3763
+ return 'auto';
3455
3764
  const stored = localStorage.getItem('preferred-theme');
3456
3765
  return stored && this.isTheme(stored) ? stored : 'auto';
3457
3766
  }
@@ -3472,9 +3781,11 @@ class ThemeService {
3472
3781
  this.renderer.removeClass(document.body, 'light');
3473
3782
  this.renderer.removeClass(document.body, 'dark');
3474
3783
  this.theme.set(theme);
3475
- localStorage.setItem('preferred-theme', theme);
3784
+ if (typeof localStorage !== 'undefined') {
3785
+ localStorage.setItem('preferred-theme', theme);
3786
+ }
3476
3787
  const effectiveClass = this.auto()
3477
- ? (this.prefersColorSchemeMediaQueryList.matches ? 'dark' : 'light')
3788
+ ? (this.prefersColorSchemeMediaQueryList?.matches ? 'dark' : 'light')
3478
3789
  : theme;
3479
3790
  this.renderer.addClass(document.body, effectiveClass);
3480
3791
  this.themeChanged.next(this.getTheme());
@@ -3488,12 +3799,12 @@ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "22.0.1", ngImpor
3488
3799
  }], ctorParameters: () => [] });
3489
3800
 
3490
3801
  /*
3491
- * Public API Surface of ars-utils
3802
+ * Public API Surface of scm-utils
3492
3803
  */
3493
3804
 
3494
3805
  /**
3495
3806
  * Generated bundle index. Do not edit.
3496
3807
  */
3497
3808
 
3498
- export { AutoFocusDirective, Breakpoints, BroadcastChannelManager, BroadcastService, CopyClipboardDirective, DateFnsAdapter, DateFormat, DateInterval, DateIntervalChangeDirective, DeleteModel, EmailsValidatorDirective, EnvironmentService, EqualsValidatorDirective, FileInfo, FileSizeValidatorDirective, FormatHtmlPipe, FormatMarkdownPipe, FormatPipe, GroupModel, GuidValidatorDirective, IDModel, ImportModel, MAT_DATE_FNS_FORMATS, MaxTermsValidatorDirective, NotEmptyValidatorDirective, NotEqualValidatorDirective, NotFutureValidatorDirective, PasswordValidatorDirective, QueryModel, RelationModel, RemoveFocusDirective, ReplacePipe, SafeHtmlPipe, SafeUrlPipe, ScreenService, SearchCallbackPipe, SearchFilterPipe, SelectableModel, SplashService, SqlDateValidatorDirective, SystemUtils, ThemeService, TimeValidatorDirective, UpdateRelationsModel, UrlValidatorDirective, UtilsMessages, ValidIfDirective, ValidatorDirective, ValueModel, provideArsCore, provideArsDateFns };
3809
+ export { AutoFocusDirective, Breakpoints, BroadcastChannelManager, BroadcastService, CopyClipboardDirective, DateFnsAdapter, DateFormat, DateInterval, DateIntervalChangeDirective, DeleteModel, EmailsValidatorDirective, EnvironmentService, EqualsValidatorDirective, FileInfo, FileSizeValidatorDirective, FormatHtmlPipe, FormatMarkdownPipe, FormatPipe, GroupModel, GuidValidatorDirective, IDModel, ImportModel, MAT_DATE_FNS_FORMATS, MaxTermsValidatorDirective, NotEmptyValidatorDirective, NotEqualValidatorDirective, NotFutureValidatorDirective, PasswordValidatorDirective, QueryModel, RelationModel, RemoveFocusDirective, ReplacePipe, SafeHtmlPipe, SafeUrlPipe, ScreenService, SearchCallbackPipe, SearchFilterPipe, SelectableModel, SplashService, SqlDateValidatorDirective, SystemUtils, ThemeService, TimeValidatorDirective, UpdateRelationsModel, UrlValidatorDirective, UtilsMessages, ValidIfDirective, ValidatorDirective, ValueModel, provideSCMDateFns };
3499
3810
  //# sourceMappingURL=arsedizioni-ars-utils-core.mjs.map